typeclaw 0.10.0 → 0.11.1
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 +5 -1
- package/package.json +1 -1
- package/src/agent/index.ts +37 -4
- package/src/agent/multimodal/look-at.ts +8 -0
- 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 +3 -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/policies/prompt-injection.ts +1 -1
- package/src/channels/adapters/discord-bot-invite.ts +89 -0
- 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/adapters/kakaotalk-classify.ts +13 -1
- package/src/channels/adapters/kakaotalk.ts +2 -0
- package/src/channels/router.ts +269 -34
- package/src/channels/schema.ts +8 -7
- package/src/channels/types.ts +1 -1
- package/src/cli/channel.ts +138 -52
- package/src/cli/init.ts +139 -100
- package/src/cli/inspect-controller.ts +66 -0
- package/src/cli/inspect.ts +24 -32
- package/src/cli/prompt-pem.ts +113 -0
- package/src/cli/run.ts +24 -5
- package/src/cli/tui.ts +34 -10
- package/src/cli/tunnel.ts +453 -14
- package/src/cli/ui.ts +22 -0
- package/src/compose/discover.ts +5 -0
- package/src/config/config.ts +35 -7
- package/src/config/providers.ts +64 -56
- package/src/init/env-file.ts +66 -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 +5 -1
- package/src/inspect/loop.ts +12 -1
- package/src/inspect/replay.ts +15 -1
- package/src/run/codex-fetch-observer.ts +377 -0
- package/src/run/index.ts +14 -2
- package/src/server/command-runner.ts +31 -2
- package/src/server/index.ts +59 -1
- package/src/shared/protocol.ts +1 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +47 -1
- 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 +25 -7
package/src/server/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SessionManager } from '@mariozechner/pi-coding-agent'
|
|
1
2
|
import type { Server as BunServer, ServerWebSocket } from 'bun'
|
|
2
3
|
|
|
3
4
|
import {
|
|
@@ -10,6 +11,7 @@ import { runPluginDoctorChecks, runPluginDoctorFix } from '@/agent/doctor'
|
|
|
10
11
|
import type { LiveSessionRegistry } from '@/agent/live-sessions'
|
|
11
12
|
import type { LiveSubagentRegistry } from '@/agent/live-subagents'
|
|
12
13
|
import { detectProviderError } from '@/agent/provider-error'
|
|
14
|
+
import { consumeRestartHandoff, type RestartHandoff } from '@/agent/restart-handoff'
|
|
13
15
|
import type { SessionOrigin } from '@/agent/session-origin'
|
|
14
16
|
import { parseSubagentCompletedPayload, renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
|
|
15
17
|
import type { CreateSessionForSubagent } from '@/agent/subagents'
|
|
@@ -233,6 +235,42 @@ export function createServer({
|
|
|
233
235
|
}: ServerOptions) {
|
|
234
236
|
const sessionStates = new WeakMap<Ws, SessionState>()
|
|
235
237
|
const callIdToWs = new Map<string, AnyOwnerWs>()
|
|
238
|
+
|
|
239
|
+
// The first TUI WS open per container lifetime checks for
|
|
240
|
+
// `.typeclaw/restart-pending.json`; subsequent opens see null. The
|
|
241
|
+
// in-flight promise serializes concurrent first-opens — two TUIs
|
|
242
|
+
// reconnecting at the same instant share the single consume() call rather
|
|
243
|
+
// than each racing to reopen the originator's JSONL. Once the promise
|
|
244
|
+
// resolves, the handoff is consumed exactly once: subsequent opens see
|
|
245
|
+
// `handoffPending === false` and return null without checking the file.
|
|
246
|
+
let handoffInFlight: Promise<RestartHandoff | null> | null = null
|
|
247
|
+
let handoffPending = true
|
|
248
|
+
async function takeRestartHandoff(): Promise<RestartHandoff | null> {
|
|
249
|
+
if (!handoffPending) return null
|
|
250
|
+
if (handoffInFlight !== null) return handoffInFlight
|
|
251
|
+
if (agentDir === undefined) {
|
|
252
|
+
handoffPending = false
|
|
253
|
+
return null
|
|
254
|
+
}
|
|
255
|
+
handoffInFlight = consumeRestartHandoff(agentDir).catch(() => null)
|
|
256
|
+
const result = await handoffInFlight
|
|
257
|
+
handoffPending = false
|
|
258
|
+
handoffInFlight = null
|
|
259
|
+
return result
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function resumeFromHandoff(handoff: RestartHandoff, factory: SessionFactory | undefined): SessionManager | null {
|
|
263
|
+
if (factory === undefined) return null
|
|
264
|
+
const sessionPath = `${factory.sessionDir()}/${handoff.originatingSessionFile}`
|
|
265
|
+
try {
|
|
266
|
+
return SessionManager.open(sessionPath)
|
|
267
|
+
} catch (err) {
|
|
268
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
269
|
+
logger.warn(`restart-handoff: failed to reopen ${sessionPath}: ${message}`)
|
|
270
|
+
return null
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
236
274
|
const commandRunner: CommandRunner | undefined = commandRunnerFactory
|
|
237
275
|
? commandRunnerFactory({
|
|
238
276
|
stdout(callId, chunk) {
|
|
@@ -397,7 +435,9 @@ export function createServer({
|
|
|
397
435
|
if (rawWs.data.kind === 'inspect') return
|
|
398
436
|
const ws = rawWs as Ws
|
|
399
437
|
try {
|
|
400
|
-
const
|
|
438
|
+
const handoff = await takeRestartHandoff()
|
|
439
|
+
const resumed = handoff !== null ? resumeFromHandoff(handoff, sessionFactory) : null
|
|
440
|
+
const sessionManager = resumed ?? sessionFactory?.createPersisted()
|
|
401
441
|
const sessionFileId = sessionManager?.getSessionId() ?? ws.data.sessionId
|
|
402
442
|
// Snapshot the runtime once so the entire session lifecycle for this
|
|
403
443
|
// ws connection sees one consistent generation of registry+hooks. A
|
|
@@ -485,6 +525,24 @@ export function createServer({
|
|
|
485
525
|
...(runtimeVersion !== undefined ? { serverVersion: runtimeVersion } : {}),
|
|
486
526
|
})
|
|
487
527
|
console.log(`session ${sessionFileId}: open`)
|
|
528
|
+
|
|
529
|
+
// Fire the post-restart kick. The originator's JSONL already
|
|
530
|
+
// contains the `typeclaw.restart-self` custom message entry that
|
|
531
|
+
// the dying container appended (see subscribeRestartNotice in
|
|
532
|
+
// src/agent/index.ts). pi's buildSessionContext() hydrates that
|
|
533
|
+
// entry as a `role: "user"` LLM message on the next prompt, so
|
|
534
|
+
// a single-space kick is enough to trigger a turn — the entry's
|
|
535
|
+
// own text instructs the model to "briefly confirm the restart
|
|
536
|
+
// completed". Publish AFTER the session-target subscription is
|
|
537
|
+
// wired (state.unsubPrompts above) so the kick is enqueued, not
|
|
538
|
+
// dropped on the floor.
|
|
539
|
+
if (resumed !== null && stream) {
|
|
540
|
+
stream.publish({
|
|
541
|
+
target: { kind: 'session', sessionId: sessionFileId },
|
|
542
|
+
payload: { kind: 'prompt', text: ' ', delivery: 'queue' },
|
|
543
|
+
meta: { source: 'restart-handoff' },
|
|
544
|
+
})
|
|
545
|
+
}
|
|
488
546
|
} catch (err) {
|
|
489
547
|
const message = err instanceof Error ? err.message : String(err)
|
|
490
548
|
console.error(`session ${ws.data.sessionId}: open failed: ${message}`)
|
package/src/shared/protocol.ts
CHANGED
|
@@ -28,7 +28,7 @@ export type TunnelRequestId = string
|
|
|
28
28
|
|
|
29
29
|
export type TunnelSnapshot = {
|
|
30
30
|
name: string
|
|
31
|
-
provider: 'external' | 'cloudflare-quick'
|
|
31
|
+
provider: 'external' | 'cloudflare-quick' | 'cloudflare-named'
|
|
32
32
|
for: { kind: 'channel'; name: string } | { kind: 'manual' }
|
|
33
33
|
url: string | null
|
|
34
34
|
status: 'stopped' | 'starting' | 'healthy' | 'unhealthy' | 'permanently-failed'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
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
|
|
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`, AND ALWAYS when an inbound says "requested your review on PR #N" or "requested a review from team @… on PR #N" (the agent has been assigned as a reviewer and must do a real code review with line-by-line comments via `gh api`). 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
4
|
---
|
|
5
5
|
|
|
6
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.
|
|
@@ -22,3 +22,49 @@ gh pr create --repo owner/repo --title "Fix: ..." --head my-branch --base main -
|
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
For App auth, `GH_TOKEN` is an installation access token that refreshes automatically — it stays current as long as the adapter is running.
|
|
25
|
+
|
|
26
|
+
## Reviewing pull requests
|
|
27
|
+
|
|
28
|
+
When an incoming message says **"requested your review on PR #N"** (or "requested a review from team @… on PR #N"), you have been assigned as a reviewer. Do a real code review and post line-by-line comments via `gh api`. Do **not** just reply in the channel — the user wants feedback on the diff.
|
|
29
|
+
|
|
30
|
+
### Workflow
|
|
31
|
+
|
|
32
|
+
1. **Read the diff and context**:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
gh pr diff <N> --repo owner/repo
|
|
36
|
+
gh pr view <N> --repo owner/repo --json title,body,baseRefName,headRefOid,files
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
2. **Submit a multi-comment review** in one API call by piping a JSON payload to `gh api --input -`. `comments[]` accepts line-level entries; each one lands on the diff exactly like a human reviewer's inline comment:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
cat <<'JSON' | gh api -X POST /repos/owner/repo/pulls/<N>/reviews --input -
|
|
43
|
+
{
|
|
44
|
+
"event": "COMMENT",
|
|
45
|
+
"body": "Overall: looks good with a few nits.",
|
|
46
|
+
"comments": [
|
|
47
|
+
{ "path": "src/foo.ts", "line": 42, "side": "RIGHT", "body": "nit: prefer `const` here." },
|
|
48
|
+
{ "path": "src/bar.ts", "line": 10, "side": "RIGHT", "body": "Consider extracting this branch into a helper." }
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
JSON
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Always use `--input -` with a quoted heredoc (`<<'JSON'`) for review bodies.** Do **not** use `-f body=...` or `-F 'comments[][body]=...'`: those go through shell argument parsing, so backticks (\`) trigger command substitution and have to be backslash-escaped, which leaks the literal `\` into the rendered comment. The quoted heredoc passes the JSON through untouched — backticks, newlines, and `${...}` all survive verbatim. The same applies to any other `gh api` POST whose body contains backticks, embedded newlines, or shell metacharacters.
|
|
55
|
+
|
|
56
|
+
3. **Then** post a one-line summary with `channel_reply` so the conversation has a human-readable trace pointing at the review.
|
|
57
|
+
|
|
58
|
+
### Rules
|
|
59
|
+
|
|
60
|
+
- Use `event=COMMENT` by default. Use `APPROVE` only when you have high confidence the PR is ready to merge. Use `REQUEST_CHANGES` only when the PR has clear blockers — not for nits.
|
|
61
|
+
- **Only post comments that the author needs to act on.** Do not post praise ("looks good", "nice refactor", "great work"), affirmations of correct code, or restatements of what a line does. If every comment in your review is positive, post a top-level summary via `channel_reply` instead of a review — or skip commenting and just `APPROVE`. Inline comments are for changes, questions, and blockers, not validation.
|
|
62
|
+
- `line` is a line number **in the file**, not a position in the diff. `side: RIGHT` is the new revision (default for additions); `side: LEFT` is the old revision (use for comments on removed lines).
|
|
63
|
+
- For multi-line comments, also set `start_line` and `start_side` (same semantics).
|
|
64
|
+
- If you need to read whole files at the PR's head SHA, use `gh api /repos/owner/repo/contents/<path>?ref=<headRefOid>`.
|
|
65
|
+
- The bundled `agent-browser` is **not** for PR reviews — `gh api` is faster and more reliable. Only use the browser when the API genuinely can't reach what you need.
|
|
66
|
+
- A `review_request_removed` event means the requester un-assigned you. Stop any in-progress review work; do not post a partial review.
|
|
67
|
+
|
|
68
|
+
### Self-loop safety
|
|
69
|
+
|
|
70
|
+
The adapter will **not** wake you when you assign yourself as a reviewer (e.g., via `gh pr edit --add-reviewer`). It will only wake you when someone else requests your review.
|
|
@@ -30,6 +30,27 @@ Choose Cloudflare Quick when the user wants the easiest path:
|
|
|
30
30
|
|
|
31
31
|
For GitHub channel setup, `typeclaw channel add github` can write a channel-owned Cloudflare Quick tunnel named `github-webhook` and set `docker.file.cloudflared: true`. The first `typeclaw start` or `restart` after that rebuilds the image with `cloudflared` installed.
|
|
32
32
|
|
|
33
|
+
### Cloudflare Named Tunnel
|
|
34
|
+
|
|
35
|
+
Choose Cloudflare Named when the user has a domain on Cloudflare and wants a stable URL:
|
|
36
|
+
|
|
37
|
+
- Cloudflare account required (free), plus a domain already in their account's `Websites` list.
|
|
38
|
+
- The user creates the tunnel in the Zero Trust dashboard (`Networks → Tunnels → Create`), copies the token, and configures a Public Hostname pointing at `localhost:<port>` (where `<port>` is the in-container upstream).
|
|
39
|
+
- The URL is whatever subdomain on their domain they configured (e.g. `https://agent.example.com`).
|
|
40
|
+
- The URL never rotates. It's bound to the tunnel in the dashboard, not the process.
|
|
41
|
+
- `cloudflared` runs inside the container with `cloudflared tunnel run --token <jwt>`. The token comes from `.env`.
|
|
42
|
+
|
|
43
|
+
Use `provider: "cloudflare-named"` with `hostname: "https://..."` and `tokenEnv: "CLOUDFLARE_TUNNEL_TOKEN"` (or another env var name set in `.env`). The user must:
|
|
44
|
+
|
|
45
|
+
1. Create the tunnel in the Cloudflare dashboard.
|
|
46
|
+
2. Add at least one Public Hostname mapping `<sub>.<their-domain>` → `localhost:<port>`. A tunnel without a Public Hostname is a no-op — `cloudflared` registers but has nothing to route.
|
|
47
|
+
3. Put the dashboard-printed token in `.env` under the env var named in `tokenEnv`.
|
|
48
|
+
4. `typeclaw restart` to pick up the new tunnel and the cloudflared layer.
|
|
49
|
+
|
|
50
|
+
The `hostname` field in `typeclaw.json` is informational — typeclaw uses it for `tunnel-url-changed` events and CLI display, but `cloudflared` reads the actual hostname→upstream mapping from the dashboard. If the user changes the hostname in the dashboard, they must also update `tunnels[].hostname` in `typeclaw.json` or downstream consumers (GitHub webhook registration) will keep using the stale URL.
|
|
51
|
+
|
|
52
|
+
`upstreamPort` is not used for `cloudflare-named` — the dashboard's Public Hostname mapping captures it. The schema rejects `upstreamPort` on named tunnels to surface drift early.
|
|
53
|
+
|
|
33
54
|
### External URL
|
|
34
55
|
|
|
35
56
|
Choose External when the user already has their own reverse proxy or tunnel:
|
|
@@ -90,7 +111,18 @@ Use `typeclaw tunnel logs <name> -f` while restarting the agent if you need to w
|
|
|
90
111
|
|
|
91
112
|
### `cloudflared` is not installed
|
|
92
113
|
|
|
93
|
-
|
|
114
|
+
Both Cloudflare providers (`cloudflare-quick` and `cloudflare-named`) require `docker.file.cloudflared: true`. If it is missing, `typeclaw tunnel add` writes it automatically; otherwise add it to `typeclaw.json` by hand and run `typeclaw restart` so the Dockerfile is regenerated and the image rebuilds.
|
|
115
|
+
|
|
116
|
+
### Named tunnel says "permanently-failed" with `tokenEnv` in the detail
|
|
117
|
+
|
|
118
|
+
The env var named in `tunnels[].tokenEnv` is not set or is empty in the agent's `.env`. The provider intentionally does not retry this case — fix `.env`, then `typeclaw restart`. `cloudflared` is never spawned with a missing token.
|
|
119
|
+
|
|
120
|
+
### Named tunnel is healthy but no traffic flows
|
|
121
|
+
|
|
122
|
+
Two likely causes:
|
|
123
|
+
|
|
124
|
+
1. The Cloudflare dashboard's Public Hostname tab for this tunnel is empty. A tunnel with no public hostname is a no-op — `cloudflared` registers and waits, but Cloudflare has nothing to route to it. `curl https://<hostname>` returns Cloudflare error 530 or 1033.
|
|
125
|
+
2. The Public Hostname's upstream `localhost:<port>` does not match the in-container service port. typeclaw cannot detect this drift; the user must align the dashboard and the container.
|
|
94
126
|
|
|
95
127
|
### Quick tunnel URL changed
|
|
96
128
|
|
package/src/tui/index.ts
CHANGED
|
@@ -44,6 +44,14 @@ export type TuiOptions = {
|
|
|
44
44
|
onVersionMismatch?: (info: VersionMismatch) => void
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// Outcome of a single `run()` cycle. The CLI's reconnect loop reads this to
|
|
48
|
+
// decide whether to spin again or exit. `lostConnection` is true when the
|
|
49
|
+
// WS closed AFTER the connected handshake without a deliberate /quit or
|
|
50
|
+
// Ctrl+C — exactly the case a self-restart produces, and the only one
|
|
51
|
+
// where a fresh connect can recover the session. Quit / Ctrl+C / pre-
|
|
52
|
+
// handshake errors all resolve with `lostConnection: false`.
|
|
53
|
+
export type TuiRunOutcome = { lostConnection: boolean }
|
|
54
|
+
|
|
47
55
|
export function createTui({
|
|
48
56
|
url,
|
|
49
57
|
initialPrompt,
|
|
@@ -54,7 +62,7 @@ export function createTui({
|
|
|
54
62
|
expectedVersion,
|
|
55
63
|
onVersionMismatch,
|
|
56
64
|
}: TuiOptions) {
|
|
57
|
-
async function run(): Promise<
|
|
65
|
+
async function run(): Promise<TuiRunOutcome> {
|
|
58
66
|
const terminal = createTerminal()
|
|
59
67
|
const tui = new TUI(terminal)
|
|
60
68
|
const displayUrl = redactUrl(url)
|
|
@@ -80,6 +88,8 @@ export function createTui({
|
|
|
80
88
|
exit(1)
|
|
81
89
|
throw err
|
|
82
90
|
})
|
|
91
|
+
|
|
92
|
+
let userInitiatedShutdown = false
|
|
83
93
|
const { sessionId, serverVersion } = handshake
|
|
84
94
|
status.setText(colors.dim(`session: ${sessionId}`))
|
|
85
95
|
tui.requestRender()
|
|
@@ -196,11 +206,11 @@ export function createTui({
|
|
|
196
206
|
}
|
|
197
207
|
})
|
|
198
208
|
|
|
199
|
-
const closed = new Promise<
|
|
209
|
+
const closed = new Promise<boolean>((resolve) => {
|
|
200
210
|
client.onClose(() => {
|
|
201
211
|
appendHistory(new Text(colors.dim('disconnected'), 0, 0))
|
|
202
212
|
tui.requestRender()
|
|
203
|
-
resolve()
|
|
213
|
+
resolve(!userInitiatedShutdown)
|
|
204
214
|
})
|
|
205
215
|
})
|
|
206
216
|
|
|
@@ -223,6 +233,7 @@ export function createTui({
|
|
|
223
233
|
})
|
|
224
234
|
|
|
225
235
|
const shutdown = (code: number) => {
|
|
236
|
+
userInitiatedShutdown = true
|
|
226
237
|
tui.stop()
|
|
227
238
|
client.close()
|
|
228
239
|
exit(code)
|
|
@@ -268,13 +279,14 @@ export function createTui({
|
|
|
268
279
|
// instead of leaking the command into the agent's chat context.
|
|
269
280
|
if (isQuitCommand(initialPrompt)) {
|
|
270
281
|
shutdown(0)
|
|
271
|
-
return
|
|
282
|
+
return { lostConnection: false }
|
|
272
283
|
}
|
|
273
284
|
await send(initialPrompt)
|
|
274
285
|
}
|
|
275
286
|
|
|
276
|
-
await closed
|
|
287
|
+
const lostConnection = await closed
|
|
277
288
|
tui.stop()
|
|
289
|
+
return { lostConnection }
|
|
278
290
|
}
|
|
279
291
|
|
|
280
292
|
return { run }
|
package/src/tunnels/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { createTunnelManager, type TunnelManager, type TunnelManagerOptions, type TunnelManagerLogger } from './manager'
|
|
2
|
+
export { createCloudflareNamedProvider, type CloudflareNamedProviderOptions } from './providers/cloudflare-named'
|
|
2
3
|
export { createCloudflareQuickProvider, type CloudflareQuickProviderOptions } from './providers/cloudflare-quick'
|
|
3
4
|
export {
|
|
4
5
|
type TunnelConfig,
|
package/src/tunnels/manager.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Stream } from '@/stream'
|
|
2
2
|
|
|
3
|
+
import { createCloudflareNamedProvider } from './providers/cloudflare-named'
|
|
3
4
|
import { createCloudflareQuickProvider } from './providers/cloudflare-quick'
|
|
4
5
|
import { createExternalProvider } from './providers/external'
|
|
5
6
|
import type { TunnelConfig, TunnelProviderHandle, TunnelState, TunnelUrlChangedPayload } from './types'
|
|
@@ -15,6 +16,11 @@ export type TunnelManagerOptions = {
|
|
|
15
16
|
stream: Stream
|
|
16
17
|
resolveChannelUpstreamPort?: (channelName: string) => number | null
|
|
17
18
|
cloudflareQuickBinary?: string
|
|
19
|
+
cloudflareNamedBinary?: string
|
|
20
|
+
// Reads an env var by name. Defaults to `process.env[name]` in production.
|
|
21
|
+
// Parameterized so tests can drive the named-provider token path without
|
|
22
|
+
// poking global env and so the manager stays a pure function of its inputs.
|
|
23
|
+
resolveEnv?: (name: string) => string | undefined
|
|
18
24
|
logger?: TunnelManagerLogger
|
|
19
25
|
}
|
|
20
26
|
|
|
@@ -36,6 +42,7 @@ const consoleLogger: TunnelManagerLogger = {
|
|
|
36
42
|
export function createTunnelManager(options: TunnelManagerOptions): TunnelManager {
|
|
37
43
|
const logger = options.logger ?? consoleLogger
|
|
38
44
|
const handles = new Map<string, TunnelProviderHandle>()
|
|
45
|
+
const resolveEnv = options.resolveEnv ?? ((name: string) => process.env[name])
|
|
39
46
|
|
|
40
47
|
for (const config of options.tunnels) {
|
|
41
48
|
const handle = buildProvider(
|
|
@@ -43,6 +50,8 @@ export function createTunnelManager(options: TunnelManagerOptions): TunnelManage
|
|
|
43
50
|
options.resolveChannelUpstreamPort,
|
|
44
51
|
(url) => publishUrlChange(options.stream, config, url, logger),
|
|
45
52
|
options.cloudflareQuickBinary,
|
|
53
|
+
options.cloudflareNamedBinary,
|
|
54
|
+
resolveEnv,
|
|
46
55
|
)
|
|
47
56
|
handles.set(config.name, handle)
|
|
48
57
|
}
|
|
@@ -92,6 +101,8 @@ function buildProvider(
|
|
|
92
101
|
resolveChannelUpstreamPort: TunnelManagerOptions['resolveChannelUpstreamPort'],
|
|
93
102
|
onUrlChange: (url: string) => void,
|
|
94
103
|
cloudflareQuickBinary: string | undefined,
|
|
104
|
+
cloudflareNamedBinary: string | undefined,
|
|
105
|
+
resolveEnv: (name: string) => string | undefined,
|
|
95
106
|
): TunnelProviderHandle {
|
|
96
107
|
switch (config.provider) {
|
|
97
108
|
case 'external':
|
|
@@ -103,6 +114,13 @@ function buildProvider(
|
|
|
103
114
|
onUrlChange,
|
|
104
115
|
binary: cloudflareQuickBinary,
|
|
105
116
|
})
|
|
117
|
+
case 'cloudflare-named':
|
|
118
|
+
return createCloudflareNamedProvider({
|
|
119
|
+
config,
|
|
120
|
+
onUrlChange,
|
|
121
|
+
resolveToken: () => (config.tokenEnv !== undefined ? resolveEnv(config.tokenEnv) : undefined),
|
|
122
|
+
binary: cloudflareNamedBinary,
|
|
123
|
+
})
|
|
106
124
|
}
|
|
107
125
|
}
|
|
108
126
|
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import type { Unsubscribe } from '@/stream'
|
|
2
|
+
|
|
3
|
+
import { createLogRing, type LogLineSubscriber, type LogRing } from '../log-ring'
|
|
4
|
+
import type { TunnelConfig, TunnelProviderHandle, TunnelState } from '../types'
|
|
5
|
+
|
|
6
|
+
const DEFAULT_BINARY = 'cloudflared'
|
|
7
|
+
const DEFAULT_RESTART_BACKOFF_MS = [1_000, 2_000, 4_000, 10_000, 30_000]
|
|
8
|
+
const DEFAULT_MAX_CONSECUTIVE_CRASHES = 10
|
|
9
|
+
const DEFAULT_STOP_GRACE_MS = 5_000
|
|
10
|
+
|
|
11
|
+
export type CloudflareNamedProviderOptions = {
|
|
12
|
+
config: TunnelConfig
|
|
13
|
+
onUrlChange: (url: string) => void
|
|
14
|
+
// Token resolver. Production wiring reads `process.env[config.tokenEnv]`;
|
|
15
|
+
// the resolver is parameterized so tests can inject a value without poking
|
|
16
|
+
// global env. Returning `undefined` (or empty string) at any call fails the
|
|
17
|
+
// start with a clear error pointing at the env-var name.
|
|
18
|
+
resolveToken: () => string | undefined
|
|
19
|
+
binary?: string
|
|
20
|
+
restartBackoffMs?: number[]
|
|
21
|
+
maxConsecutiveCrashes?: number
|
|
22
|
+
stopGraceMs?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type CloudflareNamedProviderHandle = TunnelProviderHandle & {
|
|
26
|
+
tail: () => string[]
|
|
27
|
+
subscribeToLogs: (cb: LogLineSubscriber) => Unsubscribe
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createCloudflareNamedProvider(options: CloudflareNamedProviderOptions): CloudflareNamedProviderHandle {
|
|
31
|
+
const { config, onUrlChange, resolveToken } = options
|
|
32
|
+
if (config.provider !== 'cloudflare-named') {
|
|
33
|
+
throw new Error(`createCloudflareNamedProvider: provider must be 'cloudflare-named', got '${config.provider}'`)
|
|
34
|
+
}
|
|
35
|
+
const hostname = config.hostname
|
|
36
|
+
if (hostname === undefined || hostname.trim() === '') {
|
|
37
|
+
throw new Error(`tunnel '${config.name}' (cloudflare-named): hostname is required`)
|
|
38
|
+
}
|
|
39
|
+
const tokenEnv = config.tokenEnv
|
|
40
|
+
if (tokenEnv === undefined || tokenEnv.trim() === '') {
|
|
41
|
+
throw new Error(`tunnel '${config.name}' (cloudflare-named): tokenEnv is required`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const binary = options.binary ?? DEFAULT_BINARY
|
|
45
|
+
const restartBackoffMs = options.restartBackoffMs ?? DEFAULT_RESTART_BACKOFF_MS
|
|
46
|
+
const maxConsecutiveCrashes = options.maxConsecutiveCrashes ?? DEFAULT_MAX_CONSECUTIVE_CRASHES
|
|
47
|
+
const stopGraceMs = options.stopGraceMs ?? DEFAULT_STOP_GRACE_MS
|
|
48
|
+
const logs = createLogRing()
|
|
49
|
+
const state: TunnelState = {
|
|
50
|
+
name: config.name,
|
|
51
|
+
provider: 'cloudflare-named',
|
|
52
|
+
for: config.for,
|
|
53
|
+
url: null,
|
|
54
|
+
status: 'stopped',
|
|
55
|
+
lastUrlAt: null,
|
|
56
|
+
detail: '',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let started = false
|
|
60
|
+
let stopping = false
|
|
61
|
+
let proc: ReturnType<typeof Bun.spawn> | null = null
|
|
62
|
+
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
|
63
|
+
let consecutiveCrashes = 0
|
|
64
|
+
|
|
65
|
+
async function launch(): Promise<void> {
|
|
66
|
+
if (!started || stopping) return
|
|
67
|
+
|
|
68
|
+
const token = resolveToken()
|
|
69
|
+
if (token === undefined || token.trim() === '') {
|
|
70
|
+
// Bad config rather than a transient process crash: the user-facing fix
|
|
71
|
+
// is editing `.env`, not waiting for backoff. Flip straight to
|
|
72
|
+
// permanently-failed so `tunnel status` makes the cause obvious and we
|
|
73
|
+
// don't waste retries spawning a cloudflared we know will reject the
|
|
74
|
+
// missing token.
|
|
75
|
+
state.status = 'permanently-failed'
|
|
76
|
+
state.detail = `env var ${tokenEnv} is unset or empty; set it in .env and restart`
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
state.status = 'starting'
|
|
81
|
+
state.detail = 'starting cloudflared'
|
|
82
|
+
const spawned = Bun.spawn([binary, 'tunnel', '--no-autoupdate', 'run', '--token', token], {
|
|
83
|
+
stdout: 'ignore',
|
|
84
|
+
stderr: 'pipe',
|
|
85
|
+
})
|
|
86
|
+
proc = spawned
|
|
87
|
+
|
|
88
|
+
// Mark healthy on the FIRST stderr line. cloudflared with a valid token
|
|
89
|
+
// prints registration progress to stderr within ~1s of start; a process
|
|
90
|
+
// that exits before printing anything is almost certainly a token/network
|
|
91
|
+
// failure. Healthy != "traffic flowing" — only Cloudflare's edge knows
|
|
92
|
+
// that — but it's the strongest signal available locally and matches the
|
|
93
|
+
// quick provider's "saw something on stderr" health model.
|
|
94
|
+
//
|
|
95
|
+
// Deliberately does NOT reset `consecutiveCrashes`. A process that prints
|
|
96
|
+
// one line of stderr then crashes is a tight crash loop (bad token,
|
|
97
|
+
// network down, cloudflared bug); the counter must trip the cap. The
|
|
98
|
+
// counter resets on operator action (`stop()` then `start()` again) or
|
|
99
|
+
// on `typeclaw restart`, not on stderr noise.
|
|
100
|
+
let sawFirstLine = false
|
|
101
|
+
void pumpStderr(spawned.stderr, logs, () => {
|
|
102
|
+
if (sawFirstLine) return
|
|
103
|
+
sawFirstLine = true
|
|
104
|
+
state.status = 'healthy'
|
|
105
|
+
state.detail = 'cloudflared started'
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
void spawned.exited.then((code) => {
|
|
109
|
+
if (proc !== spawned) return
|
|
110
|
+
proc = null
|
|
111
|
+
if (!started || stopping) return
|
|
112
|
+
handleExit(code)
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function handleExit(code: number): void {
|
|
117
|
+
consecutiveCrashes += 1
|
|
118
|
+
if (consecutiveCrashes >= maxConsecutiveCrashes) {
|
|
119
|
+
state.status = 'permanently-failed'
|
|
120
|
+
state.detail = `cloudflared exited ${code}; retry cap reached after ${consecutiveCrashes} consecutive crashes`
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
state.status = 'unhealthy'
|
|
125
|
+
state.detail = `cloudflared exited ${code}; restarting`
|
|
126
|
+
const delay = restartBackoffMs[Math.min(consecutiveCrashes - 1, restartBackoffMs.length - 1)] ?? 30_000
|
|
127
|
+
retryTimer = setTimeout(() => {
|
|
128
|
+
retryTimer = null
|
|
129
|
+
void launch()
|
|
130
|
+
}, delay)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
async start(): Promise<void> {
|
|
135
|
+
if (started) return
|
|
136
|
+
started = true
|
|
137
|
+
stopping = false
|
|
138
|
+
consecutiveCrashes = 0
|
|
139
|
+
// The URL is known from config, not from cloudflared. Emit it
|
|
140
|
+
// synchronously so subscribers (channel adapters, tunnel-bridge) wire
|
|
141
|
+
// up immediately, regardless of whether cloudflared comes up healthy.
|
|
142
|
+
// For named tunnels, the URL is bound to the dashboard config — even
|
|
143
|
+
// if the local process is unhealthy, the hostname is the right value
|
|
144
|
+
// to surface in `tunnel-url-changed` events.
|
|
145
|
+
state.url = hostname
|
|
146
|
+
state.lastUrlAt = Date.now()
|
|
147
|
+
onUrlChange(hostname)
|
|
148
|
+
await launch()
|
|
149
|
+
},
|
|
150
|
+
async stop(): Promise<void> {
|
|
151
|
+
if (!started && proc === null) return
|
|
152
|
+
started = false
|
|
153
|
+
stopping = true
|
|
154
|
+
if (retryTimer !== null) {
|
|
155
|
+
clearTimeout(retryTimer)
|
|
156
|
+
retryTimer = null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const running = proc
|
|
160
|
+
proc = null
|
|
161
|
+
if (running !== null) {
|
|
162
|
+
running.kill('SIGTERM')
|
|
163
|
+
await Promise.race([
|
|
164
|
+
running.exited,
|
|
165
|
+
sleep(stopGraceMs).then(() => {
|
|
166
|
+
running.kill('SIGKILL')
|
|
167
|
+
return running.exited
|
|
168
|
+
}),
|
|
169
|
+
])
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
stopping = false
|
|
173
|
+
state.status = 'stopped'
|
|
174
|
+
state.detail = ''
|
|
175
|
+
},
|
|
176
|
+
snapshot(): TunnelState {
|
|
177
|
+
return { ...state }
|
|
178
|
+
},
|
|
179
|
+
tail(): string[] {
|
|
180
|
+
return logs.snapshot()
|
|
181
|
+
},
|
|
182
|
+
subscribeToLogs(cb: LogLineSubscriber): Unsubscribe {
|
|
183
|
+
return logs.subscribe(cb)
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function pumpStderr(
|
|
189
|
+
stream: ReadableStream<Uint8Array> | null,
|
|
190
|
+
logs: LogRing,
|
|
191
|
+
onLine: (line: string) => void,
|
|
192
|
+
): Promise<void> {
|
|
193
|
+
if (stream === null) return
|
|
194
|
+
const reader = stream.getReader()
|
|
195
|
+
const decoder = new TextDecoder()
|
|
196
|
+
let buffered = ''
|
|
197
|
+
try {
|
|
198
|
+
while (true) {
|
|
199
|
+
const { done, value } = await reader.read()
|
|
200
|
+
if (done) break
|
|
201
|
+
buffered += decoder.decode(value, { stream: true })
|
|
202
|
+
let newlineIndex = buffered.indexOf('\n')
|
|
203
|
+
while (newlineIndex !== -1) {
|
|
204
|
+
const line = buffered.slice(0, newlineIndex).replace(/\r$/, '')
|
|
205
|
+
logs.append(line)
|
|
206
|
+
onLine(line)
|
|
207
|
+
buffered = buffered.slice(newlineIndex + 1)
|
|
208
|
+
newlineIndex = buffered.indexOf('\n')
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
buffered += decoder.decode()
|
|
212
|
+
if (buffered !== '') {
|
|
213
|
+
const line = buffered.replace(/\r$/, '')
|
|
214
|
+
logs.append(line)
|
|
215
|
+
onLine(line)
|
|
216
|
+
}
|
|
217
|
+
} finally {
|
|
218
|
+
reader.releaseLock()
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function sleep(ms: number): Promise<void> {
|
|
223
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
224
|
+
}
|
package/src/tunnels/types.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Unsubscribe } from '@/stream'
|
|
2
2
|
|
|
3
|
-
export type TunnelProvider = 'external' | 'cloudflare-quick'
|
|
3
|
+
export type TunnelProvider = 'external' | 'cloudflare-quick' | 'cloudflare-named'
|
|
4
4
|
|
|
5
5
|
export type TunnelFor = { kind: 'channel'; name: string } | { kind: 'manual' }
|
|
6
6
|
|
|
@@ -10,6 +10,22 @@ export type TunnelConfig = {
|
|
|
10
10
|
for: TunnelFor
|
|
11
11
|
externalUrl?: string
|
|
12
12
|
upstreamPort?: number
|
|
13
|
+
// cloudflare-named only: the public hostname configured in the Cloudflare
|
|
14
|
+
// dashboard (e.g. `https://agent.example.com`). typeclaw uses it verbatim
|
|
15
|
+
// for `tunnel-url-changed` events and CLI display; cloudflared itself
|
|
16
|
+
// learns the hostname → upstream mapping from the dashboard at runtime, so
|
|
17
|
+
// the value here must mirror what the user typed in `Public Hostname`. If
|
|
18
|
+
// the two drift, traffic stops flowing but typeclaw still reports the
|
|
19
|
+
// stale URL — there is no programmatic way to detect this without hitting
|
|
20
|
+
// Cloudflare's API, which we deliberately don't do.
|
|
21
|
+
hostname?: string
|
|
22
|
+
// cloudflare-named only: name of an env var (set in the agent's `.env`)
|
|
23
|
+
// that holds the tunnel token printed by the Cloudflare dashboard when the
|
|
24
|
+
// tunnel was created. The token itself never lives in typeclaw.json — only
|
|
25
|
+
// the env-var name does. The container reads `process.env[tokenEnv]` at
|
|
26
|
+
// tunnel start. Missing/empty values fail the start with a clear message
|
|
27
|
+
// pointing at the env var name.
|
|
28
|
+
tokenEnv?: string
|
|
13
29
|
}
|
|
14
30
|
|
|
15
31
|
export type TunnelStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy' | 'permanently-failed'
|