typeclaw 0.36.8 → 0.37.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 +3 -3
- package/package.json +3 -2
- package/src/agent/index.ts +31 -11
- package/src/agent/live-sessions.ts +12 -0
- package/src/agent/model-fallback.ts +17 -15
- package/src/agent/model-overrides.ts +2 -2
- package/src/agent/session-meta.ts +10 -0
- package/src/agent/subagents.ts +30 -3
- package/src/agent/system-prompt.ts +9 -3
- package/src/agent/todo/continuation-policy.ts +6 -3
- package/src/agent/todo/continuation-wiring.ts +4 -2
- package/src/agent/todo/continuation.ts +3 -3
- package/src/agent/tools/todo/index.ts +27 -4
- package/src/bundled-plugins/agent-browser/index.ts +33 -108
- package/src/bundled-plugins/agent-browser/shim.ts +3 -94
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +8 -33
- package/src/bundled-plugins/doc-render/skills/typeclaw-render-pdf/SKILL.md +2 -2
- package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +7 -1
- package/src/bundled-plugins/memory/README.md +80 -23
- package/src/bundled-plugins/memory/append-tool.ts +74 -53
- package/src/bundled-plugins/memory/citation-superset.ts +4 -0
- package/src/bundled-plugins/memory/citations.ts +54 -0
- package/src/bundled-plugins/memory/dreaming-metrics.ts +30 -0
- package/src/bundled-plugins/memory/dreaming.ts +444 -21
- package/src/bundled-plugins/memory/index.ts +544 -400
- package/src/bundled-plugins/memory/load-memory.ts +87 -10
- package/src/bundled-plugins/memory/load-shards.ts +48 -22
- package/src/bundled-plugins/memory/memory-logger.ts +95 -106
- package/src/bundled-plugins/memory/memory-retrieval.ts +3 -3
- package/src/bundled-plugins/memory/parent-link.ts +33 -0
- package/src/bundled-plugins/memory/paths.ts +12 -0
- package/src/bundled-plugins/memory/references/frontmatter.ts +197 -0
- package/src/bundled-plugins/memory/references/load-references.ts +212 -0
- package/src/bundled-plugins/memory/references/store-reference-tool.ts +59 -0
- package/src/bundled-plugins/memory/search-tool.ts +282 -45
- package/src/bundled-plugins/memory/stream-events.ts +1 -0
- package/src/bundled-plugins/memory/stream-io.ts +28 -3
- package/src/bundled-plugins/memory/turn-dedup.ts +40 -0
- package/src/bundled-plugins/memory/vector/cache-write.ts +19 -0
- package/src/bundled-plugins/memory/vector/config.ts +28 -0
- package/src/bundled-plugins/memory/vector/doctor.ts +124 -0
- package/src/bundled-plugins/memory/vector/embedder.ts +246 -0
- package/src/bundled-plugins/memory/vector/hybrid.ts +439 -0
- package/src/bundled-plugins/memory/vector/index-on-write.ts +34 -0
- package/src/bundled-plugins/memory/vector/inspect.ts +111 -0
- package/src/bundled-plugins/memory/vector/passages.ts +125 -0
- package/src/bundled-plugins/memory/vector/reference-index-on-write.ts +50 -0
- package/src/bundled-plugins/memory/vector/relevance-gate.ts +93 -0
- package/src/bundled-plugins/memory/vector/startup.ts +71 -0
- package/src/bundled-plugins/memory/vector/store.ts +203 -0
- package/src/bundled-plugins/memory/vector/truncation.ts +124 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +2 -0
- package/src/channels/router.ts +239 -40
- package/src/cli/incomplete-init.ts +57 -0
- package/src/cli/init.ts +166 -18
- package/src/cli/inspect.ts +11 -5
- package/src/cli/model.ts +115 -36
- package/src/cli/provider.ts +5 -3
- package/src/cli/restart.ts +24 -0
- package/src/cli/start.ts +24 -0
- package/src/cli/tunnel.ts +53 -8
- package/src/config/config.ts +110 -19
- package/src/config/index.ts +5 -1
- package/src/config/models-mutation.ts +29 -11
- package/src/config/providers-mutation.ts +2 -2
- package/src/config/providers.ts +146 -12
- package/src/container/shared.ts +9 -0
- package/src/container/start.ts +87 -4
- package/src/cron/consumer.ts +13 -7
- package/src/hostd/models.ts +64 -0
- package/src/hostd/paths.ts +6 -0
- package/src/hostd/portbroker-manager.ts +2 -2
- package/src/init/checkpoint.ts +201 -0
- package/src/init/dockerfile.ts +121 -34
- package/src/init/gitignore.ts +7 -7
- package/src/init/index.ts +41 -9
- package/src/init/models-dev.ts +96 -21
- package/src/init/oauth-login.ts +3 -3
- package/src/init/progress.ts +29 -0
- package/src/init/validate-api-key.ts +4 -0
- package/src/inspect/index.ts +13 -6
- package/src/inspect/item-list.ts +11 -2
- package/src/inspect/live-list.ts +65 -0
- package/src/inspect/open-item.ts +22 -1
- package/src/inspect/session-list.ts +29 -0
- package/src/models/embedding-model.ts +114 -0
- package/src/models/transformers-version.ts +55 -0
- package/src/plugin/types.ts +3 -0
- package/src/portbroker/container-server.ts +23 -0
- package/src/portbroker/forward-request-bus.ts +35 -0
- package/src/portbroker/forward-result-bus.ts +2 -3
- package/src/portbroker/hostd-client.ts +182 -36
- package/src/portbroker/index.ts +6 -1
- package/src/portbroker/protocol.ts +9 -2
- package/src/run/channel-session-factory.ts +11 -1
- package/src/run/index.ts +65 -8
- package/src/server/command-runner.ts +24 -1
- package/src/server/index.ts +42 -8
- package/src/shared/index.ts +2 -0
- package/src/shared/protocol.ts +31 -0
- package/src/skills/typeclaw-channels/SKILL.md +4 -4
- package/src/skills/typeclaw-config/SKILL.md +2 -2
- package/src/skills/typeclaw-memory/SKILL.md +3 -1
- package/src/skills/typeclaw-permissions/SKILL.md +3 -3
- package/src/skills/typeclaw-skills/SKILL.md +1 -1
- package/src/skills/typeclaw-tunnels/SKILL.md +22 -1
- package/src/tunnels/providers/cloudflare-quick.ts +65 -7
- package/src/tunnels/upstream-probe.ts +25 -0
- package/typeclaw.schema.json +156 -67
- package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +0 -170
- package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +0 -421
- package/src/portbroker/bind-with-forward.ts +0 -102
|
@@ -55,6 +55,10 @@ export type CommandRunnerOptions = {
|
|
|
55
55
|
// wire for the handler/command path.
|
|
56
56
|
channelRouter: ChannelRouter | undefined
|
|
57
57
|
mcpManager?: McpManager
|
|
58
|
+
// When true, prompt sessions spawned here omit the system-prompt `# Memory`
|
|
59
|
+
// section (vector agents inject memory per-turn). Forwarded to createSession
|
|
60
|
+
// so command/handler sessions stay coherent with the rest of the runtime.
|
|
61
|
+
suppressSystemMemory?: boolean
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
type CommandHandle = {
|
|
@@ -194,6 +198,7 @@ export function createCommandRunner(opts: CommandRunnerOptions): CommandRunner {
|
|
|
194
198
|
signal: abortController.signal,
|
|
195
199
|
sessionFactory: opts.sessionFactory,
|
|
196
200
|
channelRouter: opts.channelRouter,
|
|
201
|
+
...(opts.suppressSystemMemory !== undefined ? { suppressSystemMemory: opts.suppressSystemMemory } : {}),
|
|
197
202
|
...(opts.mcpManager !== undefined ? { mcpManager: opts.mcpManager } : {}),
|
|
198
203
|
}),
|
|
199
204
|
subagent: (subName, payload) =>
|
|
@@ -380,6 +385,7 @@ export async function runPromptForCommand(args: {
|
|
|
380
385
|
// so the spawned session exposes `channel_send`.
|
|
381
386
|
channelRouter?: ChannelRouter
|
|
382
387
|
mcpManager?: McpManager
|
|
388
|
+
suppressSystemMemory?: boolean
|
|
383
389
|
// Test seam for the agent-session boundary. Production passes the real
|
|
384
390
|
// `createSessionWithDispose`; tests inject a fake to verify wiring
|
|
385
391
|
// (specifically: the sessionManager handed off must be persisted, not
|
|
@@ -409,10 +415,27 @@ export async function runPromptForCommand(args: {
|
|
|
409
415
|
...(args.mcpManager !== undefined ? { mcpManager: args.mcpManager } : {}),
|
|
410
416
|
...(args.runtimeVersion !== undefined ? { runtimeVersion: args.runtimeVersion } : {}),
|
|
411
417
|
...(args.containerName !== undefined ? { containerName: args.containerName } : {}),
|
|
418
|
+
...(args.suppressSystemMemory !== undefined ? { suppressSystemMemory: args.suppressSystemMemory } : {}),
|
|
412
419
|
})
|
|
413
420
|
const detachAbort = bindSignalToSession(args.signal, session)
|
|
421
|
+
// Mirror the other turn drivers (TUI/channel/cron/subagent): fire
|
|
422
|
+
// session.turn.start with a retrievalContext so a vector agent — whose
|
|
423
|
+
// system-prompt `# Memory` section is suppressed — gets its long-term memory
|
|
424
|
+
// injected per-turn into the user prompt here too. Without this, command and
|
|
425
|
+
// handler prompt sessions would have no memory at all under vector mode.
|
|
426
|
+
const turnEvent = { sessionId, agentDir: args.agentDir, origin: args.origin }
|
|
427
|
+
const retrievalContext = { results: '' }
|
|
414
428
|
try {
|
|
415
|
-
await
|
|
429
|
+
await snapshot.hooks.runSessionTurnStart({ ...turnEvent, userPrompt: args.text, retrievalContext })
|
|
430
|
+
const turnText =
|
|
431
|
+
retrievalContext.results.length > 0
|
|
432
|
+
? `${renderTurnTimeAnchor()}\n\n${args.text}\n\n${retrievalContext.results}`
|
|
433
|
+
: `${renderTurnTimeAnchor()}\n\n${args.text}`
|
|
434
|
+
try {
|
|
435
|
+
await session.prompt(turnText)
|
|
436
|
+
} finally {
|
|
437
|
+
await snapshot.hooks.runSessionTurnEnd(turnEvent)
|
|
438
|
+
}
|
|
416
439
|
return session.getLastAssistantText() ?? ''
|
|
417
440
|
} finally {
|
|
418
441
|
detachAbort()
|
package/src/server/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { forgetSharedLoopGuardTool } from '@/agent/plugin-tools'
|
|
|
15
15
|
import { detectProviderError } from '@/agent/provider-error'
|
|
16
16
|
import { requestContainerRestart } from '@/agent/restart'
|
|
17
17
|
import { consumeRestartHandoff, type RestartHandoff } from '@/agent/restart-handoff'
|
|
18
|
+
import { sessionMetaPayload } from '@/agent/session-meta'
|
|
18
19
|
import type { SessionOrigin } from '@/agent/session-origin'
|
|
19
20
|
import { parseSubagentCompletedPayload, renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
|
|
20
21
|
import type { CreateSessionForSubagent } from '@/agent/subagents'
|
|
@@ -541,7 +542,12 @@ export function createServer({
|
|
|
541
542
|
state.unsubTurnOutcome = subscribeTurnOutcome(session, agentDir, origin, sessionFileId, logger)
|
|
542
543
|
}
|
|
543
544
|
|
|
544
|
-
liveSessionRegistry?.register({
|
|
545
|
+
liveSessionRegistry?.register({
|
|
546
|
+
sessionId: sessionFileId,
|
|
547
|
+
session,
|
|
548
|
+
origin: sessionMetaPayload(origin).origin,
|
|
549
|
+
registeredAtMs: Date.now(),
|
|
550
|
+
})
|
|
545
551
|
forwardSessionEvents(ws, state, logger, sessionFileId)
|
|
546
552
|
|
|
547
553
|
if (stream) {
|
|
@@ -760,17 +766,23 @@ export function createServer({
|
|
|
760
766
|
}
|
|
761
767
|
send(ws, { type: 'prompt_started', messageId: `local-${crypto.randomUUID()}`, text: msg.text })
|
|
762
768
|
const fallbackHooks = state.runtimeSnapshot?.hooks
|
|
769
|
+
const retrievalContext: { results: string } = { results: '' }
|
|
763
770
|
if (fallbackHooks !== undefined && agentDir !== undefined) {
|
|
764
771
|
await fallbackHooks.runSessionTurnStart({
|
|
765
772
|
sessionId: state.sessionFileId,
|
|
766
773
|
agentDir,
|
|
767
774
|
userPrompt: msg.text,
|
|
768
775
|
origin: state.origin,
|
|
776
|
+
retrievalContext,
|
|
769
777
|
})
|
|
770
778
|
}
|
|
771
779
|
state.lastUsage = null
|
|
772
780
|
try {
|
|
773
|
-
|
|
781
|
+
const turnText =
|
|
782
|
+
retrievalContext.results.length > 0
|
|
783
|
+
? `${renderTurnTimeAnchor()}\n\n${msg.text}\n\n${retrievalContext.results}`
|
|
784
|
+
: `${renderTurnTimeAnchor()}\n\n${msg.text}`
|
|
785
|
+
await state.session.prompt(turnText)
|
|
774
786
|
send(ws, doneMessage(state))
|
|
775
787
|
} catch (err) {
|
|
776
788
|
const message = err instanceof Error ? err.message : String(err)
|
|
@@ -1019,15 +1031,24 @@ function makeIdleHookCaller(state: SessionState): () => Promise<void> {
|
|
|
1019
1031
|
function makeTurnHookCallers(
|
|
1020
1032
|
state: SessionState,
|
|
1021
1033
|
agentDir: string | undefined,
|
|
1022
|
-
): { fireTurnStart: (userPrompt: string) => Promise<
|
|
1034
|
+
): { fireTurnStart: (userPrompt: string) => Promise<{ results: string }>; fireTurnEnd: () => Promise<void> } {
|
|
1023
1035
|
const hooks: HookBus | undefined = state.runtimeSnapshot?.hooks
|
|
1024
1036
|
if (hooks === undefined || agentDir === undefined) {
|
|
1025
|
-
return { fireTurnStart: async () => {}, fireTurnEnd: async () => {} }
|
|
1037
|
+
return { fireTurnStart: async () => ({ results: '' }), fireTurnEnd: async () => {} }
|
|
1026
1038
|
}
|
|
1027
1039
|
const turnEndEvent = { sessionId: state.sessionFileId, agentDir, origin: state.origin }
|
|
1028
1040
|
return {
|
|
1029
|
-
fireTurnStart: (userPrompt) =>
|
|
1030
|
-
|
|
1041
|
+
fireTurnStart: async (userPrompt) => {
|
|
1042
|
+
const retrievalContext = { results: '' }
|
|
1043
|
+
await hooks.runSessionTurnStart({
|
|
1044
|
+
sessionId: state.sessionFileId,
|
|
1045
|
+
agentDir,
|
|
1046
|
+
userPrompt,
|
|
1047
|
+
origin: state.origin,
|
|
1048
|
+
retrievalContext,
|
|
1049
|
+
})
|
|
1050
|
+
return retrievalContext
|
|
1051
|
+
},
|
|
1031
1052
|
fireTurnEnd: () => hooks.runSessionTurnEnd(turnEndEvent),
|
|
1032
1053
|
}
|
|
1033
1054
|
}
|
|
@@ -1058,10 +1079,14 @@ async function drain(
|
|
|
1058
1079
|
}).catch((err) => logger.error(`[server] ${state.sessionFileId}: todo turn-start failed: ${describeErr(err)}`))
|
|
1059
1080
|
}
|
|
1060
1081
|
|
|
1061
|
-
await fireTurnStart(item.text)
|
|
1082
|
+
const retrievalContext = await fireTurnStart(item.text)
|
|
1062
1083
|
state.lastUsage = null
|
|
1063
1084
|
try {
|
|
1064
|
-
|
|
1085
|
+
const turnText =
|
|
1086
|
+
retrievalContext.results.length > 0
|
|
1087
|
+
? `${renderTurnTimeAnchor()}\n\n${item.text}\n\n${retrievalContext.results}`
|
|
1088
|
+
: `${renderTurnTimeAnchor()}\n\n${item.text}`
|
|
1089
|
+
await state.session.prompt(turnText)
|
|
1065
1090
|
send(ws, doneMessage(state))
|
|
1066
1091
|
} catch (err) {
|
|
1067
1092
|
const message = err instanceof Error ? err.message : String(err)
|
|
@@ -1269,6 +1294,15 @@ function handleInspectMessage(
|
|
|
1269
1294
|
sendInspect(ws, { type: 'pong', id: msg.id })
|
|
1270
1295
|
return
|
|
1271
1296
|
}
|
|
1297
|
+
if (msg.type === 'list_live') {
|
|
1298
|
+
const sessions = (liveSessionRegistry?.listLive() ?? []).map((e) => ({
|
|
1299
|
+
sessionId: e.sessionId,
|
|
1300
|
+
origin: e.origin!,
|
|
1301
|
+
registeredAtMs: e.registeredAtMs ?? 0,
|
|
1302
|
+
}))
|
|
1303
|
+
sendInspect(ws, { type: 'live_sessions', sessions })
|
|
1304
|
+
return
|
|
1305
|
+
}
|
|
1272
1306
|
if (msg.type !== 'subscribe' || typeof msg.sessionId !== 'string' || msg.sessionId === '') {
|
|
1273
1307
|
sendInspect(ws, { type: 'error', message: 'invalid inspect subscription' })
|
|
1274
1308
|
ws.close()
|
package/src/shared/index.ts
CHANGED
package/src/shared/protocol.ts
CHANGED
|
@@ -60,6 +60,36 @@ export type InspectClientMessage =
|
|
|
60
60
|
// distinguish "idle" from "dead"; a missed pong can. Guards a wedged
|
|
61
61
|
// WebSocket that stays ESTABLISHED yet never fires 'close'/'error'.
|
|
62
62
|
| { type: 'ping'; id: number }
|
|
63
|
+
// One-shot query for sessions live in the container's registry but not yet on
|
|
64
|
+
// disk (pi-coding-agent defers the first .jsonl write to the first assistant
|
|
65
|
+
// message). Lets the host-stage inspect picker show in-flight sessions before
|
|
66
|
+
// their reply lands. Answered with a single `live_sessions` reply.
|
|
67
|
+
| { type: 'list_live' }
|
|
68
|
+
|
|
69
|
+
// Wire mirror of MinimalSessionOrigin (@/agent/session-meta). Duplicated rather
|
|
70
|
+
// than imported because @/shared is a leaf module — @/agent depends on it, so
|
|
71
|
+
// importing back would create a cycle. A compile-time assertion in session-meta
|
|
72
|
+
// keeps the two structurally in sync; drift fails typecheck.
|
|
73
|
+
export type LiveSessionOriginPayload =
|
|
74
|
+
| { kind: 'tui' }
|
|
75
|
+
| { kind: 'cron'; jobId: string; jobKind: 'prompt' | 'exec' | 'subagent' | 'handler' }
|
|
76
|
+
| {
|
|
77
|
+
kind: 'channel'
|
|
78
|
+
adapter: string
|
|
79
|
+
workspace: string
|
|
80
|
+
workspaceName?: string
|
|
81
|
+
chat: string
|
|
82
|
+
chatName?: string
|
|
83
|
+
thread: string | null
|
|
84
|
+
}
|
|
85
|
+
| { kind: 'subagent'; subagent: string; parentSessionId: string }
|
|
86
|
+
| { kind: 'system'; component: string }
|
|
87
|
+
|
|
88
|
+
export type LiveSessionPayload = {
|
|
89
|
+
sessionId: string
|
|
90
|
+
origin: LiveSessionOriginPayload
|
|
91
|
+
registeredAtMs: number
|
|
92
|
+
}
|
|
63
93
|
|
|
64
94
|
export type InspectFramePayload =
|
|
65
95
|
| { kind: 'text_delta'; sessionId: string; delta: string }
|
|
@@ -137,6 +167,7 @@ export type InspectServerMessage =
|
|
|
137
167
|
| { type: 'frame'; ts: number; payload: InspectFramePayload }
|
|
138
168
|
| { type: 'error'; message: string }
|
|
139
169
|
| { type: 'pong'; id: number }
|
|
170
|
+
| { type: 'live_sessions'; sessions: LiveSessionPayload[] }
|
|
140
171
|
|
|
141
172
|
export type ClientMessage =
|
|
142
173
|
| { type: 'prompt'; text: string; delivery?: PromptDelivery }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: typeclaw-channels
|
|
3
|
-
description: "TypeClaw channel behavior: how the agent decides to engage vs. stay silent on external messenger inbound (Discord, Slack, Telegram, KakaoTalk). Covers the `channels.<adapter>.engagement` triggers (mention/reply/dm), reply stickiness, the non-configurable solo-human fallback, history-prefetch windows, and the `alias` system — plain-text names the agent answers to, substring match semantics, peer-name suppressors, and engagement priority. Load when the user asks why the agent did or did not respond in a channel, wants to change when it auto-replies, asks to 'be quieter'/'stop auto-replying', wants it to answer to a nickname, or mentions engagement, stickiness, aliases, mentions, trigger words, suppressors, or '응답', '호출', '채널', '별칭', '왜 답을 안 해'. Access control (who is admitted at all) lives in `roles` — see typeclaw-permissions. The `channels`/`alias` schema, defaults, and safe-edit workflow live in typeclaw-config."
|
|
3
|
+
description: "TypeClaw channel behavior: how the agent decides to engage vs. stay silent on external messenger inbound (Discord, Slack, Telegram, LINE, KakaoTalk). Covers the `channels.<adapter>.engagement` triggers (mention/reply/dm), reply stickiness, the non-configurable solo-human fallback, history-prefetch windows, and the `alias` system — plain-text names the agent answers to, substring match semantics, peer-name suppressors, and engagement priority. Load when the user asks why the agent did or did not respond in a channel, wants to change when it auto-replies, asks to 'be quieter'/'stop auto-replying', wants it to answer to a nickname, or mentions engagement, stickiness, aliases, mentions, trigger words, suppressors, or '응답', '호출', '채널', '별칭', '왜 답을 안 해'. Access control (who is admitted at all) lives in `roles` — see typeclaw-permissions. The `channels`/`alias` schema, defaults, and safe-edit workflow live in typeclaw-config."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# typeclaw-channels
|
|
@@ -16,7 +16,7 @@ Both `channels` and `alias` are **live-reloadable** — edits take effect on the
|
|
|
16
16
|
|
|
17
17
|
## Channels
|
|
18
18
|
|
|
19
|
-
`channels` configures which external messenger adapters are enabled and how the engagement layer should behave on each. **Access control lives in `roles`, not here** — to admit a chat, declare a role match-rule that covers it (see `typeclaw-permissions`). The shape is `channels: { "<adapter-id>": { engagement, history, enabled } }`. Today the adapters are `discord-bot`, `slack-bot`, `telegram-bot`, and `kakaotalk`.
|
|
19
|
+
`channels` configures which external messenger adapters are enabled and how the engagement layer should behave on each. **Access control lives in `roles`, not here** — to admit a chat, declare a role match-rule that covers it (see `typeclaw-permissions`). The shape is `channels: { "<adapter-id>": { engagement, history, enabled } }`. Today the adapters are `discord-bot`, `slack-bot`, `telegram-bot`, `line`, and `kakaotalk`.
|
|
20
20
|
|
|
21
21
|
The channels block is **live-reloadable** — edits take effect on the next `reload`, no container restart.
|
|
22
22
|
|
|
@@ -78,8 +78,8 @@ This says: the `discord-bot` adapter is enabled with default engagement; one spe
|
|
|
78
78
|
|
|
79
79
|
This is a **`roles`** edit, not a `channels` edit. See the `typeclaw-permissions` skill for the full procedure. Short version:
|
|
80
80
|
|
|
81
|
-
1. Get the platform ID (Discord channel ID, Slack channel ID, Telegram chat ID, KakaoTalk chat ID).
|
|
82
|
-
2. Append a match-rule to `roles.member.match` using the canonical DSL (`discord:<guild>/<channel>`, `slack:<team>/<channel>`, `telegram:<chat>`, `kakao:<chat>`). Pass `acknowledgeGuards: { rolePromotion: true }` in the `write`/`edit` args — the `rolePromotion` security guard blocks any widening of `roles.<role>.match` without an ack (see `typeclaw-permissions`).
|
|
81
|
+
1. Get the platform ID (Discord channel ID, Slack channel ID, Telegram chat ID, LINE chat ID, KakaoTalk chat ID).
|
|
82
|
+
2. Append a match-rule to `roles.member.match` using the canonical DSL (`discord:<guild>/<channel>`, `slack:<team>/<channel>`, `telegram:<chat>`, for LINE the bucketed form `line:dm/<chatId>`, `line:group/<chatId>`, or `line:square/<chatId>` — pick the bucket from the chat's classification; an unbucketed `line:<chatId>` is read as a workspace and never matches — and `kakao:<chat>`). Pass `acknowledgeGuards: { rolePromotion: true }` in the `write`/`edit` args — the `rolePromotion` security guard blocks any widening of `roles.<role>.match` without an ack (see `typeclaw-permissions`).
|
|
83
83
|
3. **`roles.<role>.match[]` edits are live-reloadable** — they take effect on the next `typeclaw reload` (the classifier marks `roles.match` as `applied`, and the permission service rebuilds its role table). Only `roles.<role>.permissions[]` edits are restart-required. So adding a match-rule to admit a channel applies on `reload`; no container restart needed.
|
|
84
84
|
|
|
85
85
|
### When the user asks "stop replying in this channel"
|
|
@@ -18,7 +18,7 @@ The runtime reads `typeclaw.json` at container startup. Some fields are picked u
|
|
|
18
18
|
- `mounts` — additional host directories the user has chosen to expose to you. Each entry produces a `docker run -v <hostPath>:/agent/mounts/<name>` flag at `typeclaw start` time, so the directory shows up at `mounts/<name>` inside your agent folder. **The launcher reads this; the running container does not.** Editing `mounts` only takes effect on the next `typeclaw start`. **Restart-required.**
|
|
19
19
|
- `plugins` — array of plugin module specifiers loaded at server boot: npm package names for published plugins, or relative paths for local plugins you are authoring. **Restart-required.**
|
|
20
20
|
- `alias` — additional names the agent answers to when a channel message contains its name in plain text (no `<@id>` mention). The agent folder's directory name (`basename(agentDir)`) is always implicit; `alias` adds further forms (Latin transliteration, nicknames, Korean particles, etc.). Used by the channel engagement layer alongside the structural mention/reply/dm triggers. **Live-reloadable.**
|
|
21
|
-
- `channels` — per-adapter engagement triggers and history-prefetch knobs for external messengers (Discord, Slack, Telegram, KakaoTalk), plus the GitHub channel (a webhook-driven adapter that watches repos and reviews PRs — see **GitHub channel** below). Access control lives in `roles`, not here. **Live-reloadable** — edits take effect on the next `reload` without a container restart.
|
|
21
|
+
- `channels` — per-adapter engagement triggers and history-prefetch knobs for external messengers (Discord, Slack, Telegram, LINE, KakaoTalk), plus the GitHub channel (a webhook-driven adapter that watches repos and reviews PRs — see **GitHub channel** below). Access control lives in `roles`, not here. **Live-reloadable** — edits take effect on the next `reload` without a container restart.
|
|
22
22
|
- `docker.file` — controls what ships in the autogenerated container image. Two layers: (1) **toggles** for opinionated package installs — `tmux`, `gh`, `python`, `xvfb` default on (`true`); `cjkFonts` defaults to `"auto"` (resolved from host locale at start); `ffmpeg`, `cloudflared`, `claudeCode`, `codexCli` default off (`false`) — set a toggle to `false` to omit, or to a version string like `"2.40.0"` to apt-pin (`python`, `cjkFonts`, `cloudflared`, `xvfb`, `claudeCode`, and `codexCli` are boolean-only). Most toggles install apt packages with BuildKit cache mounts; `cloudflared`, `claudeCode`, and `codexCli` are exceptions — `cloudflared` downloads the pinned GitHub release, `claudeCode` runs Anthropic's official `curl | bash` installer, `codexCli` `bun install`s the `@openai/codex` npm package. (2) **`append`** — extra Dockerfile lines spliced in right before `ENTRYPOINT` for anything the toggles don't cover. The whole Dockerfile is rewritten on every `start` from the typeclaw template. Lives under the `docker` namespace alongside future Docker-related blocks (e.g. `docker.compose`). **Restart-required** (next `typeclaw start` rebuilds the image).
|
|
23
23
|
- `git.ignore.append` — extra `.gitignore` patterns `typeclaw start` splices into the TypeClaw-owned `.gitignore` before the protected TypeClaw rules. The whole `.gitignore` is rewritten and auto-committed on every `start` when it changes; `append` is the supported escape hatch for local ignore patterns without editing the managed file by hand. Lives under the `git` namespace. **Restart-required** (next `typeclaw start` refreshes and commits `.gitignore`).
|
|
24
24
|
- `portForward` — allow/deny policy for the auto port-forwarder (the host-stage `_hostd` daemon's portbroker). When the agent runs a server inside the container that LISTENs on a TCP port, the broker proxies it to the same port number on `127.0.0.1` of the host so the user can hit it directly. `portForward` decides which ports are allowed through. **Restart-required** — the broker captures the policy at register time on `typeclaw start`.
|
|
@@ -134,7 +134,7 @@ The reference is **a lookup table, not a wishlist** — recommending a path ther
|
|
|
134
134
|
|
|
135
135
|
## Channels and Alias
|
|
136
136
|
|
|
137
|
-
`channels` configures which external adapters (`discord-bot`, `slack-bot`, `telegram-bot`, `kakaotalk`, and `github`) are enabled and how the engagement layer behaves on each; `alias` lists plain-text names the agent answers to. Both are **live-reloadable** — edits take effect on the next `reload`, no container restart.
|
|
137
|
+
`channels` configures which external adapters (`discord-bot`, `slack-bot`, `telegram-bot`, `line`, `kakaotalk`, and `github`) are enabled and how the engagement layer behaves on each; `alias` lists plain-text names the agent answers to. Both are **live-reloadable** — edits take effect on the next `reload`, no container restart.
|
|
138
138
|
|
|
139
139
|
This skill owns only the **schema and edit mechanics** of these two fields (see the schema table above): `channels: { "<adapter-id>": { engagement, history, enabled } }` and `alias: [...]`. The **behavioral contract** for the messenger adapters — when the agent wakes to reply vs. observes, engagement triggers (mention/reply/dm), reply stickiness, the non-configurable solo-human fallback, alias substring-match semantics, and peer-name suppressors — lives in the **`typeclaw-channels`** skill. **Load `typeclaw-channels` before answering any "why did/didn't the agent respond", "make it quieter", "answer to this nickname", or engagement/alias-behavior question.** Editing the fields here still follows the standard safe-edit workflow (read whole file, validate, write back, commit); since both are live-reloadable, tell the user the change takes effect on the next `reload` — no container restart.
|
|
140
140
|
|
|
@@ -9,7 +9,9 @@ The agent's long-term memory is sharded across files in `memory/topics/<slug>.md
|
|
|
9
9
|
|
|
10
10
|
## Reading
|
|
11
11
|
|
|
12
|
-
The `# Memory` section
|
|
12
|
+
The `# Memory` section comes from topic shards only. Undreamed daily-stream events are **not** injected — call `memory_search` when you need them. When total shard bytes are above the 16 KB injection budget (or when speaking in a channel), shard bodies are dropped — only the heading + `cites=N, days=N, lastReinforced=YYYY-MM-DD` shows; call `memory_search` to fetch the bodies you need. The same `memory_search` covers both surfaces (topic shards and undreamed stream events), so one tool call reaches everything.
|
|
13
|
+
|
|
14
|
+
**Where the `# Memory` section lives depends on `memory.vector.enabled`.** With vector **off** (default), it's part of the system prompt, snapshotted once at session creation. With vector **on**, it is removed from the system prompt and injected fresh into each **user turn** instead: under budget you get all shard bodies, over budget you get the top-K shards/fragments most relevant to the current message (hybrid vector + keyword search). This keeps the system-prompt cache prefix stable across a session and lets retrieval track the current topic instead of a stale session-start snapshot. Either way `memory_search` remains available on demand.
|
|
13
15
|
|
|
14
16
|
## Writing
|
|
15
17
|
|
|
@@ -63,7 +63,7 @@ For each user turn, the current speaker's effective role is delivered in the tur
|
|
|
63
63
|
```
|
|
64
64
|
tui # any TUI session
|
|
65
65
|
* # any channel session, any platform
|
|
66
|
-
<platform>:* # any chat on this platform (slack | discord | telegram | kakao)
|
|
66
|
+
<platform>:* # any chat on this platform (slack | discord | telegram | line | kakao)
|
|
67
67
|
<platform>:<workspace> # one workspace, any chat
|
|
68
68
|
<platform>:<workspace>/<chat> # one specific chat
|
|
69
69
|
<platform>:dm/* # any DM on this platform
|
|
@@ -74,7 +74,7 @@ kakao:open/* # any KakaoTalk open chat
|
|
|
74
74
|
|
|
75
75
|
`cron`, `subagent`, and `subagent:<name>` are also valid parser shapes (they parse without error), but they do **not** grant a role to a running cron or subagent session — those resolve from stamped provenance (`scheduledByRole` / `spawnedByRole`) instead. Don't write those rules expecting them to admit traffic the way channel rules do.
|
|
76
76
|
|
|
77
|
-
Within a single string, tokens are **AND**'d. Across multiple strings in `match[]`, they're **OR**'d. The platform names are exactly `slack | discord | telegram | kakao`. Workspace and chat coordinates are platform-native IDs (Slack team `T0123`, Discord guild `123456789012345678`, Telegram chat `42`, KakaoTalk chat hash) — **never** display names. If the user gives you a name, you need to resolve it to an ID before writing the match rule.
|
|
77
|
+
Within a single string, tokens are **AND**'d. Across multiple strings in `match[]`, they're **OR**'d. The platform names are exactly `slack | discord | telegram | line | kakao`. Workspace and chat coordinates are platform-native IDs (Slack team `T0123`, Discord guild `123456789012345678`, Telegram chat `42`, LINE chat ID, KakaoTalk chat hash) — **never** display names. If the user gives you a name, you need to resolve it to an ID before writing the match rule.
|
|
78
78
|
|
|
79
79
|
Things the DSL rejects (the parser emits actionable errors at boot, but you should not write these in the first place):
|
|
80
80
|
|
|
@@ -149,7 +149,7 @@ To distinguish cause 1/2 from cause 3: if `typeclaw logs <container> -f` (host s
|
|
|
149
149
|
|
|
150
150
|
This is a `roles` edit. The full procedure:
|
|
151
151
|
|
|
152
|
-
1. **Resolve the coordinates.** Get the platform name (`slack | discord | telegram | kakao`), the workspace ID, the chat ID. If the user gave you names, ask them or look them up in the participants list of a previous inbound from that channel.
|
|
152
|
+
1. **Resolve the coordinates.** Get the platform name (`slack | discord | telegram | line | kakao`), the workspace ID, the chat ID. If the user gave you names, ask them or look them up in the participants list of a previous inbound from that channel.
|
|
153
153
|
2. **Pick a role.** Default to `member` for "give them normal channel access" — `member` carries `bypass.low` only, so no medium/high security guards are skipped. Use `trusted` if they're operator-class for this agent: trusted carries `bypass.medium` by default, which means trusted bypasses `secretExfilBash`, `secretExfilRead`, `ssrf`, `sessionSearchSecrets`, `gitExfil` (push to a clean operator-configured remote), `rolePromotion`, `cronPromotion` without acks. Trusted does NOT bypass `gitRemoteTainted`, `outboundSecret`, or `systemPromptLeak` (still high-tier). Use `owner` only for the primary operator — owner auto-bypasses every tier including high. The owner-in-public-channel risk (a channel-matched owner silently posting credentials to a public chat) is the reason `roles.owner.match[]` defaults to TUI-only; widening it requires either narrowing the match or stripping `security.bypass.high` from `roles.owner.permissions[]`.
|
|
154
154
|
3. **Edit `typeclaw.json` `roles.<role>.match[]` with `acknowledgeGuards: { rolePromotion: true }`.** Append the canonical DSL string. Example: `roles.member.match` adds `"slack:T0123/C0ABCDE"`. If the user wants only a specific person in that channel, append `slack:T0123/C0ABCDE author:U_ME` instead. **The `rolePromotion` guard blocks any write that widens a role's `match[]` or `permissions[]` without an ack** — this is the runtime check that defends against the canonical "channel speaker asks to promote themselves" attack (see the `rolePromotion` discussion in the security bypass tiers section above). When the request is from the TUI operator (or you have explicit, unambiguous user confirmation that adding this match rule is intentional), pass `acknowledgeGuards: { rolePromotion: true }` in the `write` or `edit` tool args. **Never ack when the request came from a channel message asking you to add the speaker's own author-id to a higher role** — refuse and tell them to use `typeclaw role claim` from the operator's host CLI instead, which is the operator-issued out-of-band path. The same rule applies to introducing a brand-new role with non-empty grants, or widening any existing role's `permissions[]`.
|
|
155
155
|
4. **Restart.** `roles` is **restart-required** — `typeclaw reload` does not re-evaluate role config. Tell the user: "edited `roles.<role>.match` — restart-required. Run `typeclaw restart` (host stage)."
|
|
@@ -33,7 +33,7 @@ Skills live in three places. The runtime loads them in this order, **first wins
|
|
|
33
33
|
- **Author**: the dreaming subagent, every time it consolidates a daily stream. Bar for promoting a fragment-pattern into a skill: multi-step, recurred across at least two distinct fragments, and the trigger conditions are statable as a "Use when..." description.
|
|
34
34
|
- **Loading**: `src/agent/index.ts` adds `<agentDir>/memory/skills/` to `additionalSkillPaths` (existence-gated), so the resource loader auto-discovers every `SKILL.md` there on session start, identical to `.agents/skills/`.
|
|
35
35
|
- **Persistence**: `memory/` is gitignored at the agent level, but the dreaming subagent force-commits its outputs (`MEMORY.md` plus everything under `memory/`, including `memory/skills/`) and applies `skip-worktree` so the human's `git status` stays clean.
|
|
36
|
-
- **You must not write to `memory/skills/` manually.** It is owned by the dreaming subagent. Hand-authored content there will be ignored by the part of the system that dreaming reads (it consolidates from `memory/yyyy-MM-dd.
|
|
36
|
+
- **You must not write to `memory/skills/` manually.** It is owned by the dreaming subagent. Hand-authored content there will be ignored by the part of the system that dreaming reads (it consolidates from `memory/streams/yyyy-MM-dd.jsonl`, not from existing skill files), and the dreaming subagent may overwrite the same path on a future run. If you want a hand-authored skill, put it in `.agents/skills/`.
|
|
37
37
|
|
|
38
38
|
The collision rule (first wins) means: if a downloaded skill happens to share a name with a bundled one, the bundled one still wins and the downloaded copy is silently dropped with a collision diagnostic. Useful as a safety net, but do not rely on it — pick non-colliding names.
|
|
39
39
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: typeclaw-tunnels
|
|
3
|
-
description: Use when the user mentions tunnel, ngrok, webhook URL, cloudflared, expose to internet, show my friend, public URL, GitHub webhook, port forward to public, reverse proxy, trycloudflare, or making a container-local service reachable from the internet. Read it before suggesting tunnel add/remove/status/logs or editing typeclaw.json tunnels[].
|
|
3
|
+
description: Use when the user mentions tunnel, ngrok, webhook URL, cloudflared, expose to internet, show my friend, public URL, GitHub webhook, port forward to public, reverse proxy, trycloudflare, or making a container-local service reachable from the internet. Read it before suggesting tunnel add/remove/status/logs or editing typeclaw.json tunnels[]. Also read it the moment a tunnel "doesn't work": a Cloudflare tunnel with no public URL usually means `cloudflared` was never baked into the image — it is opt-in (`docker.file.cloudflared`, default false), so a hand-added tunnel needs it set explicitly. Diagnose root cause by reading typeclaw.json + checking `command -v cloudflared` rather than trusting a single error line; tell the user to set `docker.file.cloudflared: true` and `typeclaw restart`; never curl/vendor cloudflared yourself or report a cryptic error as if the tunnel were down.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# typeclaw-tunnels
|
|
@@ -107,6 +107,27 @@ Unhealthy logs often show:
|
|
|
107
107
|
|
|
108
108
|
Use `typeclaw tunnel logs <name> -f` while restarting the agent if you need to watch URL discovery live.
|
|
109
109
|
|
|
110
|
+
## Diagnosing "the tunnel doesn't work" (you, the agent)
|
|
111
|
+
|
|
112
|
+
When a tunnel has no public URL, **diagnose the root cause directly — don't stop at a single error line.** The most common cause by far is that `cloudflared` was never baked into the image (it's opt-in; see below), not a runtime outage. These checks always work from your shell inside the container:
|
|
113
|
+
|
|
114
|
+
1. **Read `typeclaw.json`.** Look at `tunnels[]` (is the tunnel even configured? which `provider`?) and `docker.file.cloudflared` (is it `true`?).
|
|
115
|
+
2. **Check the binary:** `command -v cloudflared`. If a `cloudflare-quick` / `cloudflare-named` tunnel is configured but this prints nothing, the cloudflared layer was never installed — that is the root cause (see "### `cloudflared` is not installed" below).
|
|
116
|
+
3. **Check the upstream is alive — but probe the right port per provider:**
|
|
117
|
+
- `cloudflare-quick`: the service must be listening on the tunnel's `upstreamPort` from `typeclaw.json` (e.g. `curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:<upstreamPort>/`).
|
|
118
|
+
- `cloudflare-named`: there is **no** `upstreamPort` (the schema rejects it; the dashboard's Public Hostname mapping `localhost:<port>` captures the upstream — see the named-tunnel section above). Ask the user which port the dashboard's Public Hostname points at, then probe `127.0.0.1:<that port>`.
|
|
119
|
+
- `external`: the upstream lives behind the user's own reverse proxy, so there is no container-local port to probe — skip this check unless the user names the upstream.
|
|
120
|
+
|
|
121
|
+
Then tell the user honestly and offer the fix. For the common "hand-added tunnel, no `cloudflared`" case, send something like:
|
|
122
|
+
|
|
123
|
+
> This agent has a `cloudflare-quick` tunnel configured, but `cloudflared` was never installed into the image — it's opt-in (`docker.file.cloudflared`, default `false`), and this tunnel was hand-added to `typeclaw.json` without enabling it. Want me to set `docker.file.cloudflared: true`? It's a boot setting, so after I edit it you'll run `typeclaw restart` from the host project directory, and the tunnel URL will come up.
|
|
124
|
+
|
|
125
|
+
Only after the user agrees: edit `typeclaw.json` (use the `typeclaw-config` skill), ask them to `typeclaw restart` from the **host** stage, and confirm the URL once the rebuilt container is back. Never `curl`/vendor `cloudflared` yourself.
|
|
126
|
+
|
|
127
|
+
### If `typeclaw tunnel status/list/logs` prints `✖ [object ErrorEvent]`
|
|
128
|
+
|
|
129
|
+
On older containers the in-container CLI couldn't reach the agent websocket (it resolved the port/token via `docker`, which isn't on `$PATH` inside the container), so these commands failed at the handshake with the opaque line `✖ [object ErrorEvent]`. **That is a CLI-reachability quirk, not a tunnel outage** — do not report it to the user as "the tunnel is down" or "I can't get the URL." Fall back to the direct diagnosis above (read `typeclaw.json`, `command -v cloudflared`, probe the upstream). Current containers resolve the websocket from the in-container `TYPECLAW_*` env instead, so `tunnel status` works in-container and prints a real `detail` line; if you still see `[object ErrorEvent]`, the agent is running an older build and the direct checks are authoritative.
|
|
130
|
+
|
|
110
131
|
## Common failure modes
|
|
111
132
|
|
|
112
133
|
### `cloudflared` is not installed
|
|
@@ -3,12 +3,14 @@ import type { Unsubscribe } from '@/stream'
|
|
|
3
3
|
import { createLogRing, type LogLineSubscriber, type LogRing } from '../log-ring'
|
|
4
4
|
import { extractQuickTunnelUrl } from '../quick-url-parser'
|
|
5
5
|
import type { TunnelConfig, TunnelProviderHandle, TunnelState } from '../types'
|
|
6
|
+
import { isUpstreamReachable, type UpstreamProbe } from '../upstream-probe'
|
|
6
7
|
import { isBinaryNotFound, MISSING_BINARY_DETAIL } from './cloudflared-binary'
|
|
7
8
|
|
|
8
9
|
const DEFAULT_BINARY = 'cloudflared'
|
|
9
10
|
const DEFAULT_RESTART_BACKOFF_MS = [1_000, 2_000, 4_000, 10_000, 30_000]
|
|
10
11
|
const DEFAULT_MAX_FAILURES_WITHOUT_URL = 10
|
|
11
12
|
const DEFAULT_STOP_GRACE_MS = 5_000
|
|
13
|
+
const DEFAULT_UPSTREAM_RECHECK_MS = 2_000
|
|
12
14
|
|
|
13
15
|
export type CloudflareQuickProviderOptions = {
|
|
14
16
|
config: TunnelConfig
|
|
@@ -18,6 +20,8 @@ export type CloudflareQuickProviderOptions = {
|
|
|
18
20
|
restartBackoffMs?: number[]
|
|
19
21
|
maxConsecutiveFailuresWithoutUrl?: number
|
|
20
22
|
stopGraceMs?: number
|
|
23
|
+
probeUpstream?: UpstreamProbe
|
|
24
|
+
upstreamRecheckMs?: number
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
export type CloudflareQuickProviderHandle = TunnelProviderHandle & {
|
|
@@ -38,6 +42,8 @@ export function createCloudflareQuickProvider(options: CloudflareQuickProviderOp
|
|
|
38
42
|
const restartBackoffMs = options.restartBackoffMs ?? DEFAULT_RESTART_BACKOFF_MS
|
|
39
43
|
const maxConsecutiveFailuresWithoutUrl = options.maxConsecutiveFailuresWithoutUrl ?? DEFAULT_MAX_FAILURES_WITHOUT_URL
|
|
40
44
|
const stopGraceMs = options.stopGraceMs ?? DEFAULT_STOP_GRACE_MS
|
|
45
|
+
const probeUpstream = options.probeUpstream ?? isUpstreamReachable
|
|
46
|
+
const upstreamRecheckMs = options.upstreamRecheckMs ?? DEFAULT_UPSTREAM_RECHECK_MS
|
|
41
47
|
const logs = createLogRing()
|
|
42
48
|
const state: TunnelState = {
|
|
43
49
|
name: config.name,
|
|
@@ -53,12 +59,65 @@ export function createCloudflareQuickProvider(options: CloudflareQuickProviderOp
|
|
|
53
59
|
let stopping = false
|
|
54
60
|
let proc: ReturnType<typeof Bun.spawn> | null = null
|
|
55
61
|
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
|
62
|
+
let recheckTimer: ReturnType<typeof setTimeout> | null = null
|
|
56
63
|
let restartFailuresWithoutUrl = 0
|
|
57
64
|
let attemptEmittedUrl = false
|
|
65
|
+
let broadcastedUrl: string | null = null
|
|
66
|
+
// Identifies the current live cloudflared attempt. Bumped on every launch, on
|
|
67
|
+
// process exit, and on stop. A probe captures the generation it was started
|
|
68
|
+
// under; if the process exits (into restart backoff) or the tunnel is stopped
|
|
69
|
+
// while a probe is in flight, the resolved probe sees a stale generation and
|
|
70
|
+
// bails — so it can never mark a dead process's tunnel healthy.
|
|
71
|
+
let launchGeneration = 0
|
|
72
|
+
|
|
73
|
+
function clearRecheckTimer(): void {
|
|
74
|
+
if (recheckTimer !== null) {
|
|
75
|
+
clearTimeout(recheckTimer)
|
|
76
|
+
recheckTimer = null
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// cloudflared emits the URL once, but the upstream service may still be
|
|
81
|
+
// booting. We broadcast the URL immediately (channel adapters need it) yet
|
|
82
|
+
// gate `healthy` on a real upstream probe, re-checking on an interval so the
|
|
83
|
+
// status flips to healthy the moment the service comes up — and surfaces a
|
|
84
|
+
// 502-explaining detail until then.
|
|
85
|
+
async function onQuickUrl(url: string, generation: number): Promise<void> {
|
|
86
|
+
if (generation !== launchGeneration) return
|
|
87
|
+
attemptEmittedUrl = true
|
|
88
|
+
restartFailuresWithoutUrl = 0
|
|
89
|
+
state.url = url
|
|
90
|
+
state.lastUrlAt = Date.now()
|
|
91
|
+
if (broadcastedUrl !== url) {
|
|
92
|
+
broadcastedUrl = url
|
|
93
|
+
onUrlChange(url)
|
|
94
|
+
}
|
|
95
|
+
await reprobeUpstream(generation)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function reprobeUpstream(generation: number): Promise<void> {
|
|
99
|
+
if (generation !== launchGeneration || !started || stopping || state.url === null) return
|
|
100
|
+
const reachable = await probeUpstream(upstreamPort)
|
|
101
|
+
if (generation !== launchGeneration || !started || stopping || state.url === null) return
|
|
102
|
+
if (reachable) {
|
|
103
|
+
state.status = 'healthy'
|
|
104
|
+
state.detail = 'quick tunnel URL emitted; upstream reachable'
|
|
105
|
+
clearRecheckTimer()
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
state.status = 'unhealthy'
|
|
109
|
+
state.detail = `quick tunnel URL emitted but upstream 127.0.0.1:${upstreamPort} is not reachable (requests will 502)`
|
|
110
|
+
clearRecheckTimer()
|
|
111
|
+
recheckTimer = setTimeout(() => {
|
|
112
|
+
recheckTimer = null
|
|
113
|
+
void reprobeUpstream(generation)
|
|
114
|
+
}, upstreamRecheckMs)
|
|
115
|
+
}
|
|
58
116
|
|
|
59
117
|
async function launch(): Promise<void> {
|
|
60
118
|
if (!started || stopping) return
|
|
61
119
|
|
|
120
|
+
const generation = ++launchGeneration
|
|
62
121
|
attemptEmittedUrl = false
|
|
63
122
|
state.status = 'starting'
|
|
64
123
|
state.detail = 'starting cloudflared'
|
|
@@ -81,13 +140,7 @@ export function createCloudflareQuickProvider(options: CloudflareQuickProviderOp
|
|
|
81
140
|
void pumpStderr(spawned.stderr, logs, (line) => {
|
|
82
141
|
const url = extractQuickTunnelUrl(line)
|
|
83
142
|
if (url === null) return
|
|
84
|
-
|
|
85
|
-
restartFailuresWithoutUrl = 0
|
|
86
|
-
state.url = url
|
|
87
|
-
state.status = 'healthy'
|
|
88
|
-
state.lastUrlAt = Date.now()
|
|
89
|
-
state.detail = 'quick tunnel URL emitted'
|
|
90
|
-
onUrlChange(url)
|
|
143
|
+
void onQuickUrl(url, generation)
|
|
91
144
|
})
|
|
92
145
|
|
|
93
146
|
void spawned.exited.then((code) => {
|
|
@@ -99,6 +152,8 @@ export function createCloudflareQuickProvider(options: CloudflareQuickProviderOp
|
|
|
99
152
|
}
|
|
100
153
|
|
|
101
154
|
function handleExit(code: number): void {
|
|
155
|
+
launchGeneration += 1
|
|
156
|
+
clearRecheckTimer()
|
|
102
157
|
if (!attemptEmittedUrl) restartFailuresWithoutUrl += 1
|
|
103
158
|
if (restartFailuresWithoutUrl >= maxConsecutiveFailuresWithoutUrl) {
|
|
104
159
|
state.status = 'permanently-failed'
|
|
@@ -127,6 +182,9 @@ export function createCloudflareQuickProvider(options: CloudflareQuickProviderOp
|
|
|
127
182
|
if (!started && proc === null) return
|
|
128
183
|
started = false
|
|
129
184
|
stopping = true
|
|
185
|
+
broadcastedUrl = null
|
|
186
|
+
launchGeneration += 1
|
|
187
|
+
clearRecheckTimer()
|
|
130
188
|
if (retryTimer !== null) {
|
|
131
189
|
clearTimeout(retryTimer)
|
|
132
190
|
retryTimer = null
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createConnection } from 'node:net'
|
|
2
|
+
|
|
3
|
+
// cloudflared allocates a public quick-tunnel URL even when nothing is
|
|
4
|
+
// listening upstream, so a "healthy" tunnel can still 502 every request. We
|
|
5
|
+
// probe the upstream ourselves before claiming health; refused connections,
|
|
6
|
+
// timeouts, and socket errors all count as unreachable.
|
|
7
|
+
export async function isUpstreamReachable(port: number, timeoutMs = 1_000): Promise<boolean> {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
let settled = false
|
|
10
|
+
const finish = (reachable: boolean): void => {
|
|
11
|
+
if (settled) return
|
|
12
|
+
settled = true
|
|
13
|
+
socket.destroy()
|
|
14
|
+
resolve(reachable)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const socket = createConnection({ host: '127.0.0.1', port })
|
|
18
|
+
socket.setTimeout(timeoutMs)
|
|
19
|
+
socket.once('connect', () => finish(true))
|
|
20
|
+
socket.once('timeout', () => finish(false))
|
|
21
|
+
socket.once('error', () => finish(false))
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type UpstreamProbe = (port: number) => Promise<boolean>
|