typeclaw 0.1.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/LICENSE +21 -0
- package/README.md +134 -0
- package/auth.schema.json +63 -0
- package/cron.schema.json +96 -0
- package/package.json +72 -0
- package/scripts/emit-base-dockerfile.ts +5 -0
- package/scripts/generate-schema.ts +34 -0
- package/secrets.schema.json +63 -0
- package/src/agent/auth.ts +119 -0
- package/src/agent/compaction.ts +35 -0
- package/src/agent/git-nudge.ts +95 -0
- package/src/agent/index.ts +451 -0
- package/src/agent/plugin-tools.ts +269 -0
- package/src/agent/reload-tool.ts +71 -0
- package/src/agent/self.ts +45 -0
- package/src/agent/session-origin.ts +288 -0
- package/src/agent/subagents.ts +253 -0
- package/src/agent/system-prompt.ts +68 -0
- package/src/agent/tools/channel-fetch-attachment.ts +118 -0
- package/src/agent/tools/channel-history.ts +119 -0
- package/src/agent/tools/channel-reply.ts +182 -0
- package/src/agent/tools/channel-send.ts +212 -0
- package/src/agent/tools/ddg.ts +218 -0
- package/src/agent/tools/restart.ts +122 -0
- package/src/agent/tools/stream-snapshot.ts +181 -0
- package/src/agent/tools/webfetch/fetch.ts +102 -0
- package/src/agent/tools/webfetch/index.ts +1 -0
- package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
- package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
- package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
- package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
- package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
- package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
- package/src/agent/tools/webfetch/tool.ts +281 -0
- package/src/agent/tools/webfetch/types.ts +33 -0
- package/src/agent/tools/websearch.ts +96 -0
- package/src/agent/tools/wikipedia.ts +52 -0
- package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
- package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
- package/src/bundled-plugins/agent-browser/index.ts +179 -0
- package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
- package/src/bundled-plugins/agent-browser/shim.ts +152 -0
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
- package/src/bundled-plugins/guard/index.ts +26 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
- package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
- package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
- package/src/bundled-plugins/guard/policy.ts +18 -0
- package/src/bundled-plugins/memory/README.md +71 -0
- package/src/bundled-plugins/memory/append-tool.ts +84 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
- package/src/bundled-plugins/memory/dreaming.ts +470 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
- package/src/bundled-plugins/memory/index.ts +238 -0
- package/src/bundled-plugins/memory/load-memory.ts +122 -0
- package/src/bundled-plugins/memory/memory-logger.ts +257 -0
- package/src/bundled-plugins/memory/secret-detector.ts +49 -0
- package/src/bundled-plugins/memory/watermark.ts +15 -0
- package/src/bundled-plugins/security/index.ts +35 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
- package/src/bundled-plugins/security/policy.ts +9 -0
- package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
- package/src/channels/adapters/discord-bot-classify.ts +148 -0
- package/src/channels/adapters/discord-bot.ts +640 -0
- package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
- package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
- package/src/channels/adapters/kakaotalk-classify.ts +77 -0
- package/src/channels/adapters/kakaotalk.ts +622 -0
- package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
- package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
- package/src/channels/adapters/slack-bot-classify.ts +213 -0
- package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
- package/src/channels/adapters/slack-bot-time.ts +10 -0
- package/src/channels/adapters/slack-bot.ts +881 -0
- package/src/channels/adapters/telegram-bot-classify.ts +155 -0
- package/src/channels/adapters/telegram-bot-format.ts +309 -0
- package/src/channels/adapters/telegram-bot.ts +604 -0
- package/src/channels/engagement.ts +227 -0
- package/src/channels/index.ts +21 -0
- package/src/channels/manager.ts +292 -0
- package/src/channels/membership-cache.ts +116 -0
- package/src/channels/membership-from-history.ts +53 -0
- package/src/channels/membership.ts +30 -0
- package/src/channels/participants.ts +47 -0
- package/src/channels/persistence.ts +209 -0
- package/src/channels/reloadable.ts +28 -0
- package/src/channels/router.ts +1570 -0
- package/src/channels/schema.ts +273 -0
- package/src/channels/types.ts +160 -0
- package/src/cli/channel.ts +403 -0
- package/src/cli/compose-status.ts +95 -0
- package/src/cli/compose.ts +240 -0
- package/src/cli/hostd.ts +163 -0
- package/src/cli/index.ts +27 -0
- package/src/cli/init.ts +592 -0
- package/src/cli/logs.ts +38 -0
- package/src/cli/reload.ts +68 -0
- package/src/cli/restart.ts +66 -0
- package/src/cli/run.ts +77 -0
- package/src/cli/shell.ts +33 -0
- package/src/cli/start.ts +57 -0
- package/src/cli/status.ts +178 -0
- package/src/cli/stop.ts +31 -0
- package/src/cli/tui.ts +35 -0
- package/src/cli/ui.ts +110 -0
- package/src/commands/index.ts +74 -0
- package/src/compose/discover.ts +43 -0
- package/src/compose/index.ts +25 -0
- package/src/compose/logs.ts +162 -0
- package/src/compose/restart.ts +69 -0
- package/src/compose/start.ts +62 -0
- package/src/compose/status.ts +28 -0
- package/src/compose/stop.ts +43 -0
- package/src/config/config.ts +424 -0
- package/src/config/index.ts +25 -0
- package/src/config/providers.ts +234 -0
- package/src/config/reloadable.ts +47 -0
- package/src/container/index.ts +27 -0
- package/src/container/logs.ts +37 -0
- package/src/container/port.ts +137 -0
- package/src/container/shared.ts +290 -0
- package/src/container/shell.ts +58 -0
- package/src/container/start.ts +670 -0
- package/src/container/status.ts +76 -0
- package/src/container/stop.ts +120 -0
- package/src/container/verify-running.ts +149 -0
- package/src/cron/consumer.ts +138 -0
- package/src/cron/index.ts +54 -0
- package/src/cron/reloadable.ts +64 -0
- package/src/cron/scheduler.ts +200 -0
- package/src/cron/schema.ts +96 -0
- package/src/hostd/client.ts +113 -0
- package/src/hostd/daemon.ts +587 -0
- package/src/hostd/index.ts +25 -0
- package/src/hostd/paths.ts +82 -0
- package/src/hostd/portbroker-manager.ts +101 -0
- package/src/hostd/protocol.ts +48 -0
- package/src/hostd/spawn.ts +224 -0
- package/src/hostd/supervisor.ts +60 -0
- package/src/hostd/tailscale.ts +172 -0
- package/src/hostd/version.ts +115 -0
- package/src/init/dockerfile.ts +327 -0
- package/src/init/ensure-deps.ts +152 -0
- package/src/init/gitignore.ts +46 -0
- package/src/init/hatching.ts +60 -0
- package/src/init/index.ts +786 -0
- package/src/init/kakaotalk-auth.ts +114 -0
- package/src/init/models-dev.ts +130 -0
- package/src/init/oauth-login.ts +74 -0
- package/src/init/packagejson.ts +94 -0
- package/src/init/paths.ts +2 -0
- package/src/init/run-bun-install.ts +20 -0
- package/src/markdown/chunk.ts +299 -0
- package/src/markdown/index.ts +1 -0
- package/src/plugin/context.ts +40 -0
- package/src/plugin/define.ts +35 -0
- package/src/plugin/hooks.ts +204 -0
- package/src/plugin/index.ts +63 -0
- package/src/plugin/loader.ts +111 -0
- package/src/plugin/manager.ts +136 -0
- package/src/plugin/registry.ts +145 -0
- package/src/plugin/skills.ts +62 -0
- package/src/plugin/types.ts +172 -0
- package/src/portbroker/bind-with-forward.ts +102 -0
- package/src/portbroker/container-server.ts +305 -0
- package/src/portbroker/forward-result-bus.ts +36 -0
- package/src/portbroker/hostd-client.ts +443 -0
- package/src/portbroker/index.ts +33 -0
- package/src/portbroker/policy.ts +24 -0
- package/src/portbroker/proc-net-tcp.ts +72 -0
- package/src/portbroker/protocol.ts +39 -0
- package/src/reload/client.ts +59 -0
- package/src/reload/index.ts +3 -0
- package/src/reload/registry.ts +60 -0
- package/src/reload/types.ts +13 -0
- package/src/run/bundled-plugins.ts +24 -0
- package/src/run/channel-session-factory.ts +105 -0
- package/src/run/index.ts +432 -0
- package/src/run/plugin-runtime.ts +43 -0
- package/src/run/schema-with-plugins.ts +14 -0
- package/src/secrets/index.ts +13 -0
- package/src/secrets/migrate.ts +95 -0
- package/src/secrets/schema.ts +75 -0
- package/src/secrets/storage.ts +231 -0
- package/src/server/index.ts +436 -0
- package/src/sessions/index.ts +23 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/local-time.ts +21 -0
- package/src/shared/protocol.ts +25 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
- package/src/skills/typeclaw-config/SKILL.md +643 -0
- package/src/skills/typeclaw-cron/SKILL.md +159 -0
- package/src/skills/typeclaw-git/SKILL.md +89 -0
- package/src/skills/typeclaw-memory/SKILL.md +174 -0
- package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
- package/src/skills/typeclaw-plugins/SKILL.md +594 -0
- package/src/skills/typeclaw-skills/SKILL.md +246 -0
- package/src/stream/broker.ts +161 -0
- package/src/stream/index.ts +16 -0
- package/src/stream/types.ts +69 -0
- package/src/tui/client.ts +45 -0
- package/src/tui/format.ts +317 -0
- package/src/tui/index.ts +225 -0
- package/src/tui/theme.ts +41 -0
- package/typeclaw.schema.json +826 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { PortForward } from '@/config'
|
|
2
|
+
import { resolveHostPort } from '@/container'
|
|
3
|
+
import { type Broker, createBroker, type BrokerOptions, type PortForwardEvent } from '@/portbroker'
|
|
4
|
+
|
|
5
|
+
import type { PortbrokerCallbacks, PortbrokerStartInput } from './daemon'
|
|
6
|
+
import { createTailscaleServeManager, type TailscaleExec, type TailscaleServeManager } from './tailscale'
|
|
7
|
+
|
|
8
|
+
export type PortbrokerManagerOptions = {
|
|
9
|
+
resolveHostPortFor?: (input: { containerName: string; cwd: string }) => Promise<number | null>
|
|
10
|
+
onLog?: (msg: string) => void
|
|
11
|
+
tailscaleExec?: TailscaleExec
|
|
12
|
+
createBrokerFor?: (opts: BrokerOptions) => Broker
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Glue between hostd's daemon and the portbroker package. Owns a Broker
|
|
16
|
+
// instance per registered containerName. The daemon calls start()/stop()
|
|
17
|
+
// through this manager. Reconnect after container restart works because the
|
|
18
|
+
// resolver is re-invoked on each connect attempt — see portbroker hostd-client.
|
|
19
|
+
export function createPortbrokerManager(opts: PortbrokerManagerOptions = {}): PortbrokerCallbacks & {
|
|
20
|
+
drain: () => Promise<void>
|
|
21
|
+
} {
|
|
22
|
+
const brokers = new Map<string, Broker>()
|
|
23
|
+
const tailscaleManagers = new Map<string, TailscaleServeManager>()
|
|
24
|
+
const resolver = opts.resolveHostPortFor ?? defaultResolveHostPort
|
|
25
|
+
const log = opts.onLog ?? (() => {})
|
|
26
|
+
const brokerFactory = opts.createBrokerFor ?? createBroker
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
start(input: PortbrokerStartInput) {
|
|
30
|
+
const existing = brokers.get(input.containerName)
|
|
31
|
+
if (existing) {
|
|
32
|
+
void existing.stop().catch(() => {})
|
|
33
|
+
}
|
|
34
|
+
const existingTailscale = tailscaleManagers.get(input.containerName)
|
|
35
|
+
if (existingTailscale) {
|
|
36
|
+
tailscaleManagers.delete(input.containerName)
|
|
37
|
+
void existingTailscale.stopAll().catch(() => {})
|
|
38
|
+
}
|
|
39
|
+
const tailscale = createTailscaleServeManager({
|
|
40
|
+
containerName: input.containerName,
|
|
41
|
+
exec: opts.tailscaleExec,
|
|
42
|
+
onEvent: input.onTailscaleServeEvent,
|
|
43
|
+
onLog: (msg) => log(`[tailscale:${input.containerName}] ${msg}`),
|
|
44
|
+
})
|
|
45
|
+
tailscaleManagers.set(input.containerName, tailscale)
|
|
46
|
+
const broker = brokerFactory({
|
|
47
|
+
containerName: input.containerName,
|
|
48
|
+
cwd: input.cwd,
|
|
49
|
+
policy: input.policy,
|
|
50
|
+
resolveHostPort: () => resolver({ containerName: input.containerName, cwd: input.cwd }),
|
|
51
|
+
brokerToken: input.brokerToken,
|
|
52
|
+
onEvent: (event) => {
|
|
53
|
+
input.onEvent(event)
|
|
54
|
+
if (event.kind === 'port-forward-opened') tailscale.servePort(event.port)
|
|
55
|
+
else if (event.kind === 'port-forward-closed') tailscale.stopPort(event.port)
|
|
56
|
+
},
|
|
57
|
+
onLog: (msg) => log(`[portbroker:${input.containerName}] ${msg}`),
|
|
58
|
+
})
|
|
59
|
+
brokers.set(input.containerName, broker)
|
|
60
|
+
broker.start()
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
async stop(containerName, reason) {
|
|
64
|
+
const broker = brokers.get(containerName)
|
|
65
|
+
if (!broker) return
|
|
66
|
+
brokers.delete(containerName)
|
|
67
|
+
await broker.stop()
|
|
68
|
+
const tailscale = tailscaleManagers.get(containerName)
|
|
69
|
+
if (tailscale) {
|
|
70
|
+
tailscaleManagers.delete(containerName)
|
|
71
|
+
await tailscale.stopAll()
|
|
72
|
+
}
|
|
73
|
+
log(`[portbroker:${containerName}] stopped (${reason})`)
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
forwardedPorts(containerName) {
|
|
77
|
+
const broker = brokers.get(containerName)
|
|
78
|
+
if (!broker) return []
|
|
79
|
+
return broker.forwardedPorts()
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async drain() {
|
|
83
|
+
const all = Array.from(brokers.values())
|
|
84
|
+
const tailscale = Array.from(tailscaleManagers.values())
|
|
85
|
+
brokers.clear()
|
|
86
|
+
tailscaleManagers.clear()
|
|
87
|
+
await Promise.allSettled(all.map((b) => b.stop()))
|
|
88
|
+
await Promise.allSettled(tailscale.map((t) => t.stopAll()))
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function defaultResolveHostPort(input: { containerName: string; cwd: string }): Promise<number | null> {
|
|
94
|
+
try {
|
|
95
|
+
return await resolveHostPort({ cwd: input.cwd, retryMs: 500, intervalMs: 50 })
|
|
96
|
+
} catch {
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type { PortForward, PortForwardEvent }
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { PortForward } from '@/config'
|
|
2
|
+
|
|
3
|
+
export type Request =
|
|
4
|
+
| {
|
|
5
|
+
kind: 'register'
|
|
6
|
+
containerName: string
|
|
7
|
+
cwd: string
|
|
8
|
+
restartToken?: string
|
|
9
|
+
wsHostPort?: number
|
|
10
|
+
portForward?: PortForward
|
|
11
|
+
brokerToken?: string
|
|
12
|
+
}
|
|
13
|
+
| { kind: 'deregister'; containerName: string }
|
|
14
|
+
| { kind: 'list' }
|
|
15
|
+
| { kind: 'status'; containerName: string }
|
|
16
|
+
| { kind: 'restart'; containerName: string; build?: boolean }
|
|
17
|
+
| { kind: 'http-info' }
|
|
18
|
+
| { kind: 'version' }
|
|
19
|
+
| { kind: 'shutdown' }
|
|
20
|
+
|
|
21
|
+
export type Response = { ok: true; result?: unknown } | { ok: false; reason: string }
|
|
22
|
+
|
|
23
|
+
export type ListResult = {
|
|
24
|
+
registrations: Array<{ containerName: string; cwd: string }>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type StatusResult = {
|
|
28
|
+
containerName: string
|
|
29
|
+
cwd: string
|
|
30
|
+
forwardedPorts: number[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type RestartResult = {
|
|
34
|
+
containerName: string
|
|
35
|
+
scheduled: true
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type HttpInfoResult = {
|
|
39
|
+
port: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type VersionResult = {
|
|
43
|
+
version: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ShutdownResult = {
|
|
47
|
+
scheduled: true
|
|
48
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { open, readFile, unlink, writeFile } from 'node:fs/promises'
|
|
3
|
+
|
|
4
|
+
import { isDaemonReachable, send } from './client'
|
|
5
|
+
import { ensureDirs, lockfilePath, logfilePath, pidfilePath, socketPath } from './paths'
|
|
6
|
+
import type { HttpInfoResult, VersionResult } from './protocol'
|
|
7
|
+
import { computeSourceVersion, resolveSrcRoot, UNVERSIONED_SENTINEL } from './version'
|
|
8
|
+
|
|
9
|
+
export type EnsureDaemonOptions = {
|
|
10
|
+
cliEntry: string
|
|
11
|
+
spawnTimeoutMs?: number
|
|
12
|
+
// Test seam: tests inject a deterministic version probe + respawn so the
|
|
13
|
+
// unit test can exercise the drift path without spawning a real daemon.
|
|
14
|
+
expectedVersion?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type EnsureDaemonResult =
|
|
18
|
+
| { ok: true; pid: number; spawned: boolean; respawned: boolean; httpPort: number }
|
|
19
|
+
| { ok: false; reason: string }
|
|
20
|
+
|
|
21
|
+
const DEFAULT_SPAWN_TIMEOUT_MS = 5_000
|
|
22
|
+
const SHUTDOWN_TIMEOUT_MS = 5_000
|
|
23
|
+
const POLL_INTERVAL_MS = 50
|
|
24
|
+
|
|
25
|
+
export async function ensureDaemon(opts: EnsureDaemonOptions): Promise<EnsureDaemonResult> {
|
|
26
|
+
if (await isDaemonReachable()) {
|
|
27
|
+
const expected = opts.expectedVersion ?? (await deriveExpectedVersion(opts.cliEntry))
|
|
28
|
+
const httpPort = await readHttpPort()
|
|
29
|
+
if ((await daemonVersionMatches(expected)) && httpPort !== null) {
|
|
30
|
+
return { ok: true, pid: await readPidQuiet(), spawned: false, respawned: false, httpPort }
|
|
31
|
+
}
|
|
32
|
+
const shutdownOk = await requestShutdownAndWait()
|
|
33
|
+
if (!shutdownOk) {
|
|
34
|
+
return { ok: false, reason: 'daemon version drifted but shutdown request did not complete' }
|
|
35
|
+
}
|
|
36
|
+
await ensureDirs()
|
|
37
|
+
const respawn = await ensureDaemonWithRetry(opts, 1)
|
|
38
|
+
if (!respawn.ok) return respawn
|
|
39
|
+
return { ...respawn, respawned: true }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
await ensureDirs()
|
|
43
|
+
const result = await ensureDaemonWithRetry(opts, 1)
|
|
44
|
+
if (!result.ok) return result
|
|
45
|
+
return { ...result, respawned: false }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function deriveExpectedVersion(cliEntry: string): Promise<string> {
|
|
49
|
+
const srcRoot = resolveSrcRoot(cliEntry)
|
|
50
|
+
if (srcRoot === null) return UNVERSIONED_SENTINEL
|
|
51
|
+
return computeSourceVersion({ srcRoot })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A `version` reply that doesn't deserialize cleanly (e.g. a pre-feature
|
|
55
|
+
// daemon that doesn't recognize the kind) is treated as a mismatch. Same for
|
|
56
|
+
// any non-ok response. Conservative: it's safer to over-respawn than to keep
|
|
57
|
+
// running stale code.
|
|
58
|
+
async function daemonVersionMatches(expected: string): Promise<boolean> {
|
|
59
|
+
const reply = await send({ kind: 'version' }, { timeoutMs: 1_000 })
|
|
60
|
+
if (!reply.ok) return false
|
|
61
|
+
const result = reply.result as VersionResult | undefined
|
|
62
|
+
if (!result || typeof result.version !== 'string') return false
|
|
63
|
+
return result.version === expected
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function readHttpPort(): Promise<number | null> {
|
|
67
|
+
const reply = await send({ kind: 'http-info' }, { timeoutMs: 1_000 })
|
|
68
|
+
if (!reply.ok) return null
|
|
69
|
+
const result = reply.result as HttpInfoResult | undefined
|
|
70
|
+
return typeof result?.port === 'number' && result.port > 0 && result.port <= 65535 ? result.port : null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function requestShutdownAndWait(): Promise<boolean> {
|
|
74
|
+
const reply = await send({ kind: 'shutdown' }, { timeoutMs: 1_000 })
|
|
75
|
+
if (!reply.ok) return false
|
|
76
|
+
const deadline = Date.now() + SHUTDOWN_TIMEOUT_MS
|
|
77
|
+
while (Date.now() < deadline) {
|
|
78
|
+
if (!existsSync(socketPath())) return true
|
|
79
|
+
await sleep(POLL_INTERVAL_MS)
|
|
80
|
+
}
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type SpawnAttemptResult = { ok: true; pid: number; spawned: boolean; httpPort: number } | { ok: false; reason: string }
|
|
85
|
+
|
|
86
|
+
async function ensureDaemonWithRetry(opts: EnsureDaemonOptions, retriesLeft: number): Promise<SpawnAttemptResult> {
|
|
87
|
+
const lock = await acquireLockOrWait(opts.spawnTimeoutMs ?? DEFAULT_SPAWN_TIMEOUT_MS)
|
|
88
|
+
if (lock.kind === 'daemon-reachable') {
|
|
89
|
+
const httpPort = await readHttpPort()
|
|
90
|
+
if (httpPort === null) return { ok: false, reason: 'daemon did not report an HTTP control port' }
|
|
91
|
+
return { ok: true, pid: await readPidQuiet(), spawned: false, httpPort }
|
|
92
|
+
}
|
|
93
|
+
if (lock.kind === 'stale-lock-cleared') {
|
|
94
|
+
if (retriesLeft > 0) return ensureDaemonWithRetry(opts, retriesLeft - 1)
|
|
95
|
+
return { ok: false, reason: 'stale lockfile cleared but retry budget exhausted' }
|
|
96
|
+
}
|
|
97
|
+
if (lock.kind === 'timeout') {
|
|
98
|
+
return { ok: false, reason: lock.reason }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
if (await isDaemonReachable()) {
|
|
103
|
+
const httpPort = await readHttpPort()
|
|
104
|
+
if (httpPort === null) return { ok: false, reason: 'daemon did not report an HTTP control port' }
|
|
105
|
+
return { ok: true, pid: await readPidQuiet(), spawned: false, httpPort }
|
|
106
|
+
}
|
|
107
|
+
return await spawnDaemonDetached(opts)
|
|
108
|
+
} finally {
|
|
109
|
+
await releaseLock(lock.token)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function spawnDaemonDetached(opts: EnsureDaemonOptions): Promise<SpawnAttemptResult> {
|
|
114
|
+
// Bun.spawn() with `stdout: <number>` consumes the file descriptor by
|
|
115
|
+
// dup()-ing it into the child; the parent's handle remains valid until we
|
|
116
|
+
// close it. Closing too early would race the dup. We hold the FileHandle
|
|
117
|
+
// open across spawn() and close it only after the child has been launched.
|
|
118
|
+
let handle: Awaited<ReturnType<typeof open>>
|
|
119
|
+
try {
|
|
120
|
+
handle = await open(logfilePath(), 'a')
|
|
121
|
+
} catch (error) {
|
|
122
|
+
return { ok: false, reason: `failed to open daemon log: ${stringify(error)}` }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let proc: ReturnType<typeof Bun.spawn>
|
|
126
|
+
try {
|
|
127
|
+
proc = Bun.spawn({
|
|
128
|
+
cmd: [process.execPath, opts.cliEntry, '_hostd'],
|
|
129
|
+
stdin: 'ignore',
|
|
130
|
+
stdout: handle.fd,
|
|
131
|
+
stderr: handle.fd,
|
|
132
|
+
env: { ...process.env },
|
|
133
|
+
})
|
|
134
|
+
} catch (error) {
|
|
135
|
+
handle.close().catch(() => {})
|
|
136
|
+
return { ok: false, reason: `failed to spawn daemon: ${stringify(error)}` }
|
|
137
|
+
}
|
|
138
|
+
proc.unref()
|
|
139
|
+
handle.close().catch(() => {})
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
await writeFile(pidfilePath(), `${proc.pid}\n`)
|
|
143
|
+
} catch (error) {
|
|
144
|
+
try {
|
|
145
|
+
proc.kill('SIGTERM')
|
|
146
|
+
} catch {}
|
|
147
|
+
return { ok: false, reason: `failed to write daemon pidfile: ${stringify(error)}` }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const deadline = Date.now() + (opts.spawnTimeoutMs ?? DEFAULT_SPAWN_TIMEOUT_MS)
|
|
151
|
+
while (Date.now() < deadline) {
|
|
152
|
+
if (await isDaemonReachable()) {
|
|
153
|
+
const httpPort = await readHttpPort()
|
|
154
|
+
if (httpPort !== null) return { ok: true, pid: proc.pid, spawned: true, httpPort }
|
|
155
|
+
}
|
|
156
|
+
await sleep(POLL_INTERVAL_MS)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Daemon failed to come up. Reap the orphan and clean the pidfile so the
|
|
160
|
+
// next ensureDaemon() doesn't observe a dangling pidfile pointing at our
|
|
161
|
+
// dead child.
|
|
162
|
+
try {
|
|
163
|
+
proc.kill('SIGTERM')
|
|
164
|
+
} catch {}
|
|
165
|
+
try {
|
|
166
|
+
const raw = await readFile(pidfilePath(), 'utf8').catch(() => '')
|
|
167
|
+
if (raw.trim() === String(proc.pid)) await unlink(pidfilePath())
|
|
168
|
+
} catch {}
|
|
169
|
+
return { ok: false, reason: 'daemon spawned but did not become reachable' }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
type LockToken = { path: string }
|
|
173
|
+
type LockResult =
|
|
174
|
+
| { kind: 'acquired'; token: LockToken }
|
|
175
|
+
| { kind: 'daemon-reachable' }
|
|
176
|
+
| { kind: 'stale-lock-cleared' }
|
|
177
|
+
| { kind: 'timeout'; reason: string }
|
|
178
|
+
|
|
179
|
+
async function acquireLockOrWait(timeoutMs: number): Promise<LockResult> {
|
|
180
|
+
const path = lockfilePath()
|
|
181
|
+
const deadline = Date.now() + timeoutMs
|
|
182
|
+
while (Date.now() < deadline) {
|
|
183
|
+
try {
|
|
184
|
+
const handle = await open(path, 'wx')
|
|
185
|
+
await handle.write(`${process.pid}\n`)
|
|
186
|
+
await handle.close()
|
|
187
|
+
return { kind: 'acquired', token: { path } }
|
|
188
|
+
} catch {
|
|
189
|
+
if (await isDaemonReachable()) return { kind: 'daemon-reachable' }
|
|
190
|
+
await sleep(POLL_INTERVAL_MS)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Lock held by something that never finished. Clear it so the caller can
|
|
194
|
+
// retry once. Rare in practice (only happens if a previous ensureDaemon
|
|
195
|
+
// process was killed mid-spawn).
|
|
196
|
+
try {
|
|
197
|
+
await unlink(path)
|
|
198
|
+
} catch {}
|
|
199
|
+
return { kind: 'stale-lock-cleared' }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function releaseLock(token: LockToken): Promise<void> {
|
|
203
|
+
try {
|
|
204
|
+
await unlink(token.path)
|
|
205
|
+
} catch {}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function readPidQuiet(): Promise<number> {
|
|
209
|
+
try {
|
|
210
|
+
const raw = await readFile(pidfilePath(), 'utf8')
|
|
211
|
+
const pid = Number.parseInt(raw.trim(), 10)
|
|
212
|
+
return Number.isFinite(pid) ? pid : 0
|
|
213
|
+
} catch {
|
|
214
|
+
return 0
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function sleep(ms: number): Promise<void> {
|
|
219
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function stringify(err: unknown): string {
|
|
223
|
+
return err instanceof Error ? err.message : String(err)
|
|
224
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Response } from './protocol'
|
|
2
|
+
|
|
3
|
+
export type SupervisorRestart = (input: {
|
|
4
|
+
containerName: string
|
|
5
|
+
cwd: string
|
|
6
|
+
// When true, the underlying `start()` runs with `forceBuild: true`, which
|
|
7
|
+
// regenerates the Dockerfile from the current CLI template AND rebuilds the
|
|
8
|
+
// image even if it already exists. Default false matches the host-side
|
|
9
|
+
// `typeclaw restart` (no `--build` flag) behavior.
|
|
10
|
+
build?: boolean
|
|
11
|
+
}) => Promise<{ ok: true } | { ok: false; reason: string }>
|
|
12
|
+
|
|
13
|
+
export type SupervisorOptions = {
|
|
14
|
+
restart?: SupervisorRestart
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type SupervisorLogEvent =
|
|
18
|
+
| { kind: 'restart-scheduled'; containerName: string; build: boolean }
|
|
19
|
+
| { kind: 'restart-completed'; containerName: string }
|
|
20
|
+
| { kind: 'restart-failed'; containerName: string; reason: string }
|
|
21
|
+
|
|
22
|
+
export type Supervisor = {
|
|
23
|
+
scheduleRestart: (input: { containerName: string; cwd: string; build?: boolean }) => Response
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type SupervisorBuildOptions = {
|
|
27
|
+
restart: SupervisorRestart
|
|
28
|
+
onLog: (event: SupervisorLogEvent) => void
|
|
29
|
+
isStopped: () => boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The daemon ACKs the agent immediately so the container can exit cleanly,
|
|
33
|
+
// then runs stop+start in the background. If we ran them inline the agent's
|
|
34
|
+
// own RPC connection would die when its container stopped — guaranteed to
|
|
35
|
+
// race because `docker stop` is the very thing we're about to do. Errors are
|
|
36
|
+
// surfaced via the log channel; there is no connected client to receive them.
|
|
37
|
+
export function buildSupervisor({ restart, onLog, isStopped }: SupervisorBuildOptions): Supervisor {
|
|
38
|
+
return {
|
|
39
|
+
scheduleRestart: ({ containerName, cwd, build = false }): Response => {
|
|
40
|
+
if (isStopped()) return { ok: false, reason: 'daemon stopping' }
|
|
41
|
+
onLog({ kind: 'restart-scheduled', containerName, build })
|
|
42
|
+
void runRestart()
|
|
43
|
+
return { ok: true }
|
|
44
|
+
|
|
45
|
+
async function runRestart(): Promise<void> {
|
|
46
|
+
try {
|
|
47
|
+
const result = await restart({ containerName, cwd, build })
|
|
48
|
+
if (result.ok) onLog({ kind: 'restart-completed', containerName })
|
|
49
|
+
else onLog({ kind: 'restart-failed', containerName, reason: result.reason })
|
|
50
|
+
} catch (error) {
|
|
51
|
+
onLog({
|
|
52
|
+
kind: 'restart-failed',
|
|
53
|
+
containerName,
|
|
54
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { getBun } from '@/container/shared'
|
|
2
|
+
|
|
3
|
+
export type TailscaleExecResult = { exitCode: number; stdout: string; stderr: string }
|
|
4
|
+
export type TailscaleExec = (args: string[]) => Promise<TailscaleExecResult>
|
|
5
|
+
|
|
6
|
+
export type TailscaleServeEvent =
|
|
7
|
+
| { kind: 'tailscale-serve-opened'; containerName: string; port: number }
|
|
8
|
+
| { kind: 'tailscale-serve-closed'; containerName: string; port: number }
|
|
9
|
+
| { kind: 'tailscale-serve-skipped'; containerName: string; port: number; reason: string }
|
|
10
|
+
| {
|
|
11
|
+
kind: 'tailscale-serve-failed'
|
|
12
|
+
containerName: string
|
|
13
|
+
port: number
|
|
14
|
+
command: 'status' | 'serve' | 'off'
|
|
15
|
+
reason: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type TailscaleServeManager = {
|
|
19
|
+
servePort: (port: number) => void
|
|
20
|
+
stopPort: (port: number) => void
|
|
21
|
+
stopAll: () => Promise<void>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type TailscaleServeManagerOptions = {
|
|
25
|
+
containerName: string
|
|
26
|
+
exec?: TailscaleExec
|
|
27
|
+
onEvent: (event: TailscaleServeEvent) => void
|
|
28
|
+
onLog?: (msg: string) => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type TailscaleStatus = {
|
|
32
|
+
BackendState?: unknown
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const MACOS_APP_CLI = '/Applications/Tailscale.app/Contents/MacOS/Tailscale'
|
|
36
|
+
|
|
37
|
+
export function createTailscaleServeManager(opts: TailscaleServeManagerOptions): TailscaleServeManager {
|
|
38
|
+
const exec = opts.exec ?? defaultTailscaleExec
|
|
39
|
+
const log = opts.onLog ?? (() => {})
|
|
40
|
+
const ownedPorts = new Set<number>()
|
|
41
|
+
const pending = new Set<Promise<void>>()
|
|
42
|
+
|
|
43
|
+
const track = (work: Promise<void>): void => {
|
|
44
|
+
pending.add(work)
|
|
45
|
+
work.finally(() => pending.delete(work)).catch(() => {})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const emit = (event: TailscaleServeEvent): void => {
|
|
49
|
+
try {
|
|
50
|
+
opts.onEvent(event)
|
|
51
|
+
} catch (error) {
|
|
52
|
+
log(`tailscale serve event handler threw: ${stringifyError(error)}`)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const servePort = (port: number): void => {
|
|
57
|
+
if (ownedPorts.has(port)) return
|
|
58
|
+
track(
|
|
59
|
+
(async () => {
|
|
60
|
+
const ready = await checkRunning(exec)
|
|
61
|
+
if (!ready.ok) {
|
|
62
|
+
emit({ kind: 'tailscale-serve-skipped', containerName: opts.containerName, port, reason: ready.reason })
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const result = await exec(['serve', '--bg', `--tcp=${port}`, String(port)])
|
|
67
|
+
if (result.exitCode !== 0) {
|
|
68
|
+
emit({
|
|
69
|
+
kind: 'tailscale-serve-failed',
|
|
70
|
+
containerName: opts.containerName,
|
|
71
|
+
port,
|
|
72
|
+
command: 'serve',
|
|
73
|
+
reason: commandError(result),
|
|
74
|
+
})
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
ownedPorts.add(port)
|
|
79
|
+
emit({ kind: 'tailscale-serve-opened', containerName: opts.containerName, port })
|
|
80
|
+
})(),
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const stopPort = (port: number): void => {
|
|
85
|
+
if (!ownedPorts.has(port)) return
|
|
86
|
+
track(stopOwnedPort(port))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const stopOwnedPort = async (port: number): Promise<void> => {
|
|
90
|
+
const result = await exec(['serve', `--tcp=${port}`, 'off'])
|
|
91
|
+
if (result.exitCode !== 0) {
|
|
92
|
+
emit({
|
|
93
|
+
kind: 'tailscale-serve-failed',
|
|
94
|
+
containerName: opts.containerName,
|
|
95
|
+
port,
|
|
96
|
+
command: 'off',
|
|
97
|
+
reason: commandError(result),
|
|
98
|
+
})
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
ownedPorts.delete(port)
|
|
103
|
+
emit({ kind: 'tailscale-serve-closed', containerName: opts.containerName, port })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
servePort,
|
|
108
|
+
stopPort,
|
|
109
|
+
async stopAll() {
|
|
110
|
+
await Promise.allSettled(Array.from(pending))
|
|
111
|
+
await Promise.allSettled(Array.from(ownedPorts).map((port) => stopOwnedPort(port)))
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function checkRunning(exec: TailscaleExec): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
117
|
+
const result = await exec(['status', '--json'])
|
|
118
|
+
if (result.exitCode !== 0) return { ok: false, reason: commandError(result) }
|
|
119
|
+
|
|
120
|
+
let parsed: TailscaleStatus
|
|
121
|
+
try {
|
|
122
|
+
parsed = JSON.parse(result.stdout) as TailscaleStatus
|
|
123
|
+
} catch (error) {
|
|
124
|
+
return { ok: false, reason: `invalid tailscale status JSON: ${stringifyError(error)}` }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (parsed.BackendState !== 'Running')
|
|
128
|
+
return { ok: false, reason: `tailscale backend is ${String(parsed.BackendState)}` }
|
|
129
|
+
return { ok: true }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const defaultTailscaleExec: TailscaleExec = async (args) => {
|
|
133
|
+
const candidates = process.platform === 'darwin' ? ['tailscale', MACOS_APP_CLI] : ['tailscale']
|
|
134
|
+
let lastError = 'tailscale command not found'
|
|
135
|
+
|
|
136
|
+
for (const candidate of candidates) {
|
|
137
|
+
const result = await runTailscale(candidate, args)
|
|
138
|
+
if (result.exitCode !== 127) return result
|
|
139
|
+
lastError = result.stderr || lastError
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { exitCode: 127, stdout: '', stderr: lastError }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function runTailscale(bin: string, args: string[]): Promise<TailscaleExecResult> {
|
|
146
|
+
const bun = getBun()
|
|
147
|
+
if (!bun) return { exitCode: 127, stdout: '', stderr: 'bun runtime not available' }
|
|
148
|
+
try {
|
|
149
|
+
const proc = bun.spawn({
|
|
150
|
+
cmd: [bin, ...args],
|
|
151
|
+
stdout: 'pipe',
|
|
152
|
+
stderr: 'pipe',
|
|
153
|
+
env: bin === MACOS_APP_CLI ? { ...process.env, TAILSCALE_BE_CLI: '1' } : process.env,
|
|
154
|
+
})
|
|
155
|
+
const exitCode = await proc.exited
|
|
156
|
+
const stdout = await new Response(proc.stdout).text()
|
|
157
|
+
const stderr = await new Response(proc.stderr).text()
|
|
158
|
+
return { exitCode, stdout, stderr }
|
|
159
|
+
} catch (error) {
|
|
160
|
+
const code = typeof error === 'object' && error !== null && 'code' in error ? String(error.code) : ''
|
|
161
|
+
const exitCode = code === 'ENOENT' ? 127 : 1
|
|
162
|
+
return { exitCode, stdout: '', stderr: stringifyError(error) }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function commandError(result: TailscaleExecResult): string {
|
|
167
|
+
return (result.stderr || result.stdout || `exit ${result.exitCode}`).trim()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function stringifyError(error: unknown): string {
|
|
171
|
+
return error instanceof Error ? error.message : String(error)
|
|
172
|
+
}
|