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,76 @@
|
|
|
1
|
+
import { CONTAINER_PORT } from './port'
|
|
2
|
+
import { containerNameFromCwd, defaultDockerExec, imageTagFromCwd, type DockerExec } from './shared'
|
|
3
|
+
|
|
4
|
+
export type ContainerStatus =
|
|
5
|
+
| { kind: 'missing'; containerName: string; imageTag: string }
|
|
6
|
+
| {
|
|
7
|
+
kind: 'stopped'
|
|
8
|
+
containerName: string
|
|
9
|
+
imageTag: string
|
|
10
|
+
containerId: string
|
|
11
|
+
configuredImage: string
|
|
12
|
+
}
|
|
13
|
+
| {
|
|
14
|
+
kind: 'running'
|
|
15
|
+
containerName: string
|
|
16
|
+
imageTag: string
|
|
17
|
+
containerId: string
|
|
18
|
+
configuredImage: string
|
|
19
|
+
hostPort: number | null
|
|
20
|
+
hostBindAddr: string | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type StatusOptions = {
|
|
24
|
+
cwd: string
|
|
25
|
+
exec?: DockerExec
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function status({ cwd, exec = defaultDockerExec }: StatusOptions): Promise<ContainerStatus> {
|
|
29
|
+
const containerName = containerNameFromCwd(cwd)
|
|
30
|
+
const imageTag = imageTagFromCwd(cwd)
|
|
31
|
+
|
|
32
|
+
const inspect = await exec(['inspect', '--format', '{{.State.Running}}|{{.Id}}|{{.Config.Image}}', containerName])
|
|
33
|
+
if (inspect.exitCode !== 0) {
|
|
34
|
+
return { kind: 'missing', containerName, imageTag }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const [runningRaw = '', containerId = '', configuredImage = ''] = inspect.stdout.trim().split('|')
|
|
38
|
+
const running = runningRaw.trim() === 'true'
|
|
39
|
+
|
|
40
|
+
if (!running) {
|
|
41
|
+
return { kind: 'stopped', containerName, imageTag, containerId, configuredImage }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const mapping = await queryPortMapping(exec, containerName)
|
|
45
|
+
return {
|
|
46
|
+
kind: 'running',
|
|
47
|
+
containerName,
|
|
48
|
+
imageTag,
|
|
49
|
+
containerId,
|
|
50
|
+
configuredImage,
|
|
51
|
+
hostPort: mapping?.port ?? null,
|
|
52
|
+
hostBindAddr: mapping?.bindAddr ?? null,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type PortMapping = { bindAddr: string; port: number }
|
|
57
|
+
|
|
58
|
+
// Mirrors parseDockerPortOutput in ./port but also keeps the bind address so
|
|
59
|
+
// status can show "127.0.0.1:51234 -> 8973" instead of just the host port.
|
|
60
|
+
async function queryPortMapping(exec: DockerExec, containerName: string): Promise<PortMapping | null> {
|
|
61
|
+
const result = await exec(['port', containerName, `${CONTAINER_PORT}/tcp`])
|
|
62
|
+
if (result.exitCode !== 0) return null
|
|
63
|
+
const lines = result.stdout
|
|
64
|
+
.split('\n')
|
|
65
|
+
.map((line) => line.trim())
|
|
66
|
+
.filter((line) => line.length > 0)
|
|
67
|
+
if (lines.length === 0) return null
|
|
68
|
+
|
|
69
|
+
const ipv4 = lines.find((line) => /^\d{1,3}(\.\d{1,3}){3}:\d+$/.test(line))
|
|
70
|
+
const candidate = ipv4 ?? lines[0]!
|
|
71
|
+
const lastColon = candidate.lastIndexOf(':')
|
|
72
|
+
if (lastColon < 0) return null
|
|
73
|
+
const port = Number(candidate.slice(lastColon + 1))
|
|
74
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) return null
|
|
75
|
+
return { bindAddr: candidate.slice(0, lastColon), port }
|
|
76
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { isDaemonReachable, send as sendToDaemon } from '@/hostd/client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
classifyRmStderr,
|
|
5
|
+
containerNameFromCwd,
|
|
6
|
+
defaultDockerExec,
|
|
7
|
+
type DockerExec,
|
|
8
|
+
sanitizeDockerStderr,
|
|
9
|
+
waitForRemoval,
|
|
10
|
+
} from './shared'
|
|
11
|
+
|
|
12
|
+
export type StopPlan = {
|
|
13
|
+
containerName: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type StopResult = { ok: true; containerName: string; running: boolean } | { ok: false; reason: string }
|
|
17
|
+
|
|
18
|
+
export type StopOptions = {
|
|
19
|
+
cwd: string
|
|
20
|
+
exec?: DockerExec
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function stop({ cwd, exec = defaultDockerExec }: StopOptions): Promise<StopResult> {
|
|
24
|
+
const { containerName } = planStop(cwd)
|
|
25
|
+
|
|
26
|
+
if (await isDaemonReachable()) {
|
|
27
|
+
await sendToDaemon({ kind: 'deregister', containerName })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const inspect = await exec(['inspect', '--format', '{{.State.Running}}', containerName], { cwd })
|
|
32
|
+
if (inspect.exitCode !== 0) {
|
|
33
|
+
// `docker inspect` exits non-zero both when the container does not
|
|
34
|
+
// exist AND when it exists but is in a transient state docker cannot
|
|
35
|
+
// inspect (Removal In Progress, Dead, daemon hiccup). Discriminate by
|
|
36
|
+
// stderr — same approach used for `docker rm` below — and attempt a
|
|
37
|
+
// force-remove in the latter case so a corpse holding the name does
|
|
38
|
+
// not collide with the next `docker run --name <same>`.
|
|
39
|
+
if (inspect.stderr.toLowerCase().includes('no such container')) {
|
|
40
|
+
return { ok: true, containerName, running: false }
|
|
41
|
+
}
|
|
42
|
+
const recover = await exec(['rm', '-f', containerName], { cwd })
|
|
43
|
+
if (recover.exitCode !== 0) {
|
|
44
|
+
const kind = classifyRmStderr(recover.stderr)
|
|
45
|
+
if (kind === null) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
reason: `docker inspect failed (${sanitizeDockerStderr(inspect.stderr) || 'no stderr'}) and docker rm -f could not recover: ${sanitizeDockerStderr(recover.stderr) || 'no stderr'}`,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (kind === 'in-progress' && !(await waitForRemoval(exec, containerName))) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
reason: `Container ${containerName} is still being removed by docker after 10s.`,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { ok: true, containerName, running: false }
|
|
59
|
+
}
|
|
60
|
+
const running = inspect.stdout.trim() === 'true'
|
|
61
|
+
|
|
62
|
+
// Only call `docker stop` when the container is actually running. A stopped
|
|
63
|
+
// corpse from a prior crash is left around by design (no `--rm`), and
|
|
64
|
+
// `docker stop` on an exited container would still succeed but emit a
|
|
65
|
+
// noisy warning to stderr — skip it.
|
|
66
|
+
if (running) {
|
|
67
|
+
const stopResult = await exec(['stop', containerName], { cwd })
|
|
68
|
+
if (stopResult.exitCode !== 0) {
|
|
69
|
+
return { ok: false, reason: `docker stop failed: ${sanitizeDockerStderr(stopResult.stderr) || 'no stderr'}` }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Containers run without `--rm`, so `docker stop` only stops them — the
|
|
74
|
+
// record stays in `docker ps -a` until we remove it explicitly. Remove now
|
|
75
|
+
// so a subsequent `docker run --name <same>` (e.g. from `typeclaw restart`)
|
|
76
|
+
// does not collide on the name. Use `-f` for symmetry with the start.ts
|
|
77
|
+
// preflight and because `docker stop` occasionally returns exit 0 before
|
|
78
|
+
// the container is fully out of `Running` state on OrbStack under load —
|
|
79
|
+
// bare `docker rm` would then refuse a still-running container. See
|
|
80
|
+
// classifyRmStderr for the benign-failure contract; when 'in-progress',
|
|
81
|
+
// wait for the drain so stop()'s ok-return actually means "name is free"
|
|
82
|
+
// (which compose's restart and any subsequent start() depend on).
|
|
83
|
+
//
|
|
84
|
+
// Same waitForRemoval call on the exit-0 path for the same reason as the
|
|
85
|
+
// start.ts preflight: OrbStack and Docker Desktop under load acknowledge
|
|
86
|
+
// `rm -f` before the daemon has finished draining the removal, so an
|
|
87
|
+
// immediate `docker run --name <same>` (from `typeclaw compose restart`,
|
|
88
|
+
// which fires stop→start sequentially per agent) races the drain and
|
|
89
|
+
// fails with "Conflict. The container name … is already in use by
|
|
90
|
+
// container <ID>". stop()'s contract is that the name is free on return,
|
|
91
|
+
// and the only way to honor that against Docker's async removal is to
|
|
92
|
+
// poll inspect until the container actually disappears.
|
|
93
|
+
const rmResult = await exec(['rm', '-f', containerName], { cwd })
|
|
94
|
+
if (rmResult.exitCode !== 0) {
|
|
95
|
+
const kind = classifyRmStderr(rmResult.stderr)
|
|
96
|
+
if (kind === null) {
|
|
97
|
+
return { ok: false, reason: `docker rm failed: ${sanitizeDockerStderr(rmResult.stderr) || 'no stderr'}` }
|
|
98
|
+
}
|
|
99
|
+
if (kind === 'in-progress' && !(await waitForRemoval(exec, containerName))) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
reason: `Container ${containerName} is still being removed by docker after 10s.`,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} else if (!(await waitForRemoval(exec, containerName))) {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
reason: `Container ${containerName} is still being removed by docker after 10s.`,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { ok: true, containerName, running }
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function planStop(cwd: string): StopPlan {
|
|
119
|
+
return { containerName: containerNameFromCwd(cwd) }
|
|
120
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { DockerExec } from './shared'
|
|
2
|
+
|
|
3
|
+
export type ContainerLifeStatus = 'running' | 'created' | 'restarting' | 'paused' | 'exited' | 'dead' | 'removing'
|
|
4
|
+
|
|
5
|
+
export type ContainerProbeResult =
|
|
6
|
+
| { kind: 'missing' }
|
|
7
|
+
| { kind: 'status'; status: ContainerLifeStatus }
|
|
8
|
+
| { kind: 'daemon-error'; detail: string }
|
|
9
|
+
|
|
10
|
+
export type CrashLogs = { ok: true; text: string } | { ok: false; error: string }
|
|
11
|
+
|
|
12
|
+
export type VerifyRunningResult =
|
|
13
|
+
| { ok: true }
|
|
14
|
+
| { ok: false; mode: 'removed'; logs: CrashLogs }
|
|
15
|
+
| { ok: false; mode: 'exited'; status: ContainerLifeStatus; logs: CrashLogs }
|
|
16
|
+
| { ok: false; mode: 'daemon-error'; detail: string }
|
|
17
|
+
|
|
18
|
+
export type VerifyRunningFn = (containerName: string) => Promise<VerifyRunningResult>
|
|
19
|
+
|
|
20
|
+
export type VerifyRunningOptions = {
|
|
21
|
+
exec: DockerExec
|
|
22
|
+
timeoutMs?: number
|
|
23
|
+
intervalMs?: number
|
|
24
|
+
logsTimeoutMs?: number
|
|
25
|
+
now?: () => number
|
|
26
|
+
sleep?: (ms: number) => Promise<void>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Docker reports container State.Status as one of: created, running, paused,
|
|
30
|
+
// restarting, removing, exited, dead. `created`/`restarting` are transient and
|
|
31
|
+
// must NOT be classified as crashes — `docker run -d` returns once the daemon
|
|
32
|
+
// has fired off `tsk.Start()` and called State.SetRunning, but on slow hosts
|
|
33
|
+
// (Docker Desktop on macOS, loaded swarm nodes) the in-memory transition can
|
|
34
|
+
// briefly trail the API return. Treating either as a crash produces false
|
|
35
|
+
// positives; we keep polling until the state resolves OR the deadline hits.
|
|
36
|
+
const TRANSIENT_STATUSES: ReadonlySet<ContainerLifeStatus> = new Set(['created', 'restarting'])
|
|
37
|
+
const TERMINAL_STATUSES: ReadonlySet<ContainerLifeStatus> = new Set(['exited', 'dead', 'removing'])
|
|
38
|
+
|
|
39
|
+
// Matches the stderr Docker emits when `docker inspect <name>` finds nothing.
|
|
40
|
+
// Everything else — 500s, socket errors, permission denied, daemon restart —
|
|
41
|
+
// must surface as a daemon-error rather than be misclassified as
|
|
42
|
+
// "container does not exist".
|
|
43
|
+
const NO_SUCH_CONTAINER = /no such (?:container|object)/i
|
|
44
|
+
|
|
45
|
+
export function createVerifyRunning(options: VerifyRunningOptions): VerifyRunningFn {
|
|
46
|
+
const timeoutMs = options.timeoutMs ?? 1_500
|
|
47
|
+
const intervalMs = options.intervalMs ?? 100
|
|
48
|
+
const logsTimeoutMs = options.logsTimeoutMs ?? 500
|
|
49
|
+
const now = options.now ?? Date.now
|
|
50
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
|
|
51
|
+
return async (containerName) => {
|
|
52
|
+
if (timeoutMs <= 0) return { ok: true }
|
|
53
|
+
const deadline = now() + timeoutMs
|
|
54
|
+
while (now() < deadline) {
|
|
55
|
+
const probe = await probeContainer(options.exec, containerName)
|
|
56
|
+
if (probe.kind === 'daemon-error') {
|
|
57
|
+
return { ok: false, mode: 'daemon-error', detail: probe.detail }
|
|
58
|
+
}
|
|
59
|
+
if (probe.kind === 'missing') {
|
|
60
|
+
const logs = await captureCrashLogs(options.exec, containerName, logsTimeoutMs)
|
|
61
|
+
return { ok: false, mode: 'removed', logs }
|
|
62
|
+
}
|
|
63
|
+
if (probe.status === 'running' || probe.status === 'paused') {
|
|
64
|
+
await sleepRespectingDeadline(sleep, intervalMs, now, deadline)
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
if (TRANSIENT_STATUSES.has(probe.status)) {
|
|
68
|
+
await sleepRespectingDeadline(sleep, intervalMs, now, deadline)
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
if (TERMINAL_STATUSES.has(probe.status)) {
|
|
72
|
+
const logs = await captureCrashLogs(options.exec, containerName, logsTimeoutMs)
|
|
73
|
+
return { ok: false, mode: 'exited', status: probe.status, logs }
|
|
74
|
+
}
|
|
75
|
+
await sleepRespectingDeadline(sleep, intervalMs, now, deadline)
|
|
76
|
+
}
|
|
77
|
+
return { ok: true }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function sleepRespectingDeadline(
|
|
82
|
+
sleep: (ms: number) => Promise<void>,
|
|
83
|
+
intervalMs: number,
|
|
84
|
+
now: () => number,
|
|
85
|
+
deadline: number,
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
const remaining = deadline - now()
|
|
88
|
+
if (remaining <= 0) return
|
|
89
|
+
await sleep(Math.min(intervalMs, remaining))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function probeContainer(exec: DockerExec, name: string): Promise<ContainerProbeResult> {
|
|
93
|
+
const result = await exec(['inspect', '--format', '{{.State.Status}}', name])
|
|
94
|
+
if (result.exitCode === 0) {
|
|
95
|
+
const raw = result.stdout.trim().toLowerCase()
|
|
96
|
+
if (isLifeStatus(raw)) return { kind: 'status', status: raw }
|
|
97
|
+
return { kind: 'daemon-error', detail: `docker inspect returned unrecognized status: ${raw || '<empty>'}` }
|
|
98
|
+
}
|
|
99
|
+
if (NO_SUCH_CONTAINER.test(result.stderr)) return { kind: 'missing' }
|
|
100
|
+
const detail = result.stderr.trim() || `docker inspect exited with code ${result.exitCode}`
|
|
101
|
+
return { kind: 'daemon-error', detail }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isLifeStatus(value: string): value is ContainerLifeStatus {
|
|
105
|
+
return (
|
|
106
|
+
value === 'running' ||
|
|
107
|
+
value === 'created' ||
|
|
108
|
+
value === 'restarting' ||
|
|
109
|
+
value === 'paused' ||
|
|
110
|
+
value === 'exited' ||
|
|
111
|
+
value === 'dead' ||
|
|
112
|
+
value === 'removing'
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function captureCrashLogs(exec: DockerExec, name: string, timeoutMs: number): Promise<CrashLogs> {
|
|
117
|
+
const signal = AbortSignal.timeout(timeoutMs)
|
|
118
|
+
const result = await exec(['logs', '--tail', '50', name], { signal })
|
|
119
|
+
const combined = `${result.stdout}${result.stderr}`.trim()
|
|
120
|
+
if (result.exitCode === 0) return { ok: true, text: combined }
|
|
121
|
+
if (signal.aborted) return { ok: false, error: `docker logs timed out after ${timeoutMs}ms` }
|
|
122
|
+
// Docker writes container stdout/stderr to stdout/stderr respectively for
|
|
123
|
+
// `docker logs`. Partial output is worth surfacing even when the command
|
|
124
|
+
// ultimately fails (e.g. container removed mid-read), so we keep `combined`
|
|
125
|
+
// alongside the docker-level error rather than discarding it.
|
|
126
|
+
const dockerError = result.stderr.trim() || `docker logs exited with code ${result.exitCode}`
|
|
127
|
+
if (combined.length > 0) return { ok: false, error: `${dockerError} (partial logs preserved)` }
|
|
128
|
+
return { ok: false, error: dockerError }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function buildCrashReason(name: string, failure: Extract<VerifyRunningResult, { ok: false }>): string {
|
|
132
|
+
if (failure.mode === 'daemon-error') {
|
|
133
|
+
return `Could not verify container ${name} stayed running: ${failure.detail}`
|
|
134
|
+
}
|
|
135
|
+
// We don't pass `--rm` to `docker run`, so a `removed` outcome means an
|
|
136
|
+
// external process (the user, docker prune, a CI cleanup) removed the
|
|
137
|
+
// container during our 1.5s verify window. Surface this distinctly from the
|
|
138
|
+
// ordinary `exited` crash because the logs are gone and the user needs to
|
|
139
|
+
// know why instead of chasing a phantom application bug.
|
|
140
|
+
const headline =
|
|
141
|
+
failure.mode === 'removed'
|
|
142
|
+
? `Container ${name} disappeared during start verification (an external process removed it).`
|
|
143
|
+
: `Container ${name} stopped running immediately after start (state: ${failure.status}).`
|
|
144
|
+
if (failure.logs.ok) {
|
|
145
|
+
if (failure.logs.text.length === 0) return `${headline} Container produced no logs.`
|
|
146
|
+
return `${headline} Last logs:\n${failure.logs.text}`
|
|
147
|
+
}
|
|
148
|
+
return `${headline} Could not read container logs: ${failure.logs.error}`
|
|
149
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { HookBus } from '@/plugin'
|
|
2
|
+
import type { Stream, Unsubscribe } from '@/stream'
|
|
3
|
+
|
|
4
|
+
import type { CronJob, ExecJob, PromptJob } from './schema'
|
|
5
|
+
|
|
6
|
+
// `hooks`, `sessionId`, and `getTranscriptPath` are optional so test fakes can
|
|
7
|
+
// stay one-liners. When present, the consumer fires `session.idle` after every
|
|
8
|
+
// prompt completion and `session.end` on dispose, mirroring the lifecycle
|
|
9
|
+
// signals the TUI server already emits in `src/server/index.ts`. Without this
|
|
10
|
+
// the bundled memory plugin's debounced `memory-logger` never spawns for cron
|
|
11
|
+
// prompt jobs because it only wakes on `session.idle`.
|
|
12
|
+
export type CronSession = {
|
|
13
|
+
prompt: (text: string) => Promise<void>
|
|
14
|
+
dispose?: () => void
|
|
15
|
+
hooks?: HookBus
|
|
16
|
+
sessionId?: string
|
|
17
|
+
getTranscriptPath?: () => string | undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type CronConsumerLogger = {
|
|
21
|
+
info: (msg: string) => void
|
|
22
|
+
warn: (msg: string) => void
|
|
23
|
+
error: (msg: string) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type CreateCronConsumerOptions = {
|
|
27
|
+
stream: Stream
|
|
28
|
+
cwd: string
|
|
29
|
+
createSessionForCron: (job: PromptJob) => Promise<CronSession>
|
|
30
|
+
logger?: CronConsumerLogger
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type CronConsumer = {
|
|
34
|
+
start: () => void
|
|
35
|
+
stop: () => void
|
|
36
|
+
inFlightCount: () => number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const consoleLogger: CronConsumerLogger = {
|
|
40
|
+
info: (m) => console.log(m),
|
|
41
|
+
warn: (m) => console.warn(m),
|
|
42
|
+
error: (m) => console.error(m),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createCronConsumer({
|
|
46
|
+
stream,
|
|
47
|
+
cwd,
|
|
48
|
+
createSessionForCron,
|
|
49
|
+
logger = consoleLogger,
|
|
50
|
+
}: CreateCronConsumerOptions): CronConsumer {
|
|
51
|
+
const inFlight = new Set<string>()
|
|
52
|
+
let unsubscribe: Unsubscribe | null = null
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
start() {
|
|
56
|
+
if (unsubscribe !== null) return
|
|
57
|
+
unsubscribe = stream.subscribe({ target: { kind: 'cron' } }, async (msg) => {
|
|
58
|
+
const job = msg.payload as CronJob
|
|
59
|
+
if (!isCronJob(job)) {
|
|
60
|
+
logger.warn(`[cron-consumer] received message ${msg.id} with invalid payload, ignoring`)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
if (inFlight.has(job.id)) {
|
|
64
|
+
logger.warn(`[cron] ${job.id}: previous run still in progress, skipping`)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
inFlight.add(job.id)
|
|
68
|
+
try {
|
|
69
|
+
if (job.kind === 'prompt') {
|
|
70
|
+
await runPrompt(job, createSessionForCron, stream)
|
|
71
|
+
} else {
|
|
72
|
+
await runExec(job, cwd)
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
76
|
+
logger.error(`[cron] ${job.id} failed: ${message}`)
|
|
77
|
+
} finally {
|
|
78
|
+
inFlight.delete(job.id)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
},
|
|
82
|
+
stop() {
|
|
83
|
+
unsubscribe?.()
|
|
84
|
+
unsubscribe = null
|
|
85
|
+
},
|
|
86
|
+
inFlightCount() {
|
|
87
|
+
return inFlight.size
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function runPrompt(
|
|
93
|
+
job: PromptJob,
|
|
94
|
+
createSessionForCron: (job: PromptJob) => Promise<CronSession>,
|
|
95
|
+
stream: Stream,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
if (job.subagent !== undefined) {
|
|
98
|
+
stream.publish({
|
|
99
|
+
target: { kind: 'new-session', subagent: job.subagent },
|
|
100
|
+
payload: job.payload,
|
|
101
|
+
})
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
const session = await createSessionForCron(job)
|
|
105
|
+
try {
|
|
106
|
+
await session.prompt(job.prompt)
|
|
107
|
+
if (session.hooks && session.sessionId !== undefined) {
|
|
108
|
+
await session.hooks.runSessionIdle({
|
|
109
|
+
sessionId: session.sessionId,
|
|
110
|
+
parentTranscriptPath: session.getTranscriptPath?.(),
|
|
111
|
+
idleMs: 0,
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
} finally {
|
|
115
|
+
if (session.hooks && session.sessionId !== undefined) {
|
|
116
|
+
await session.hooks.runSessionEnd({ sessionId: session.sessionId })
|
|
117
|
+
}
|
|
118
|
+
session.dispose?.()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function runExec(job: ExecJob, cwd: string): Promise<void> {
|
|
123
|
+
const [cmd, ...args] = job.command
|
|
124
|
+
if (!cmd) throw new Error(`exec job ${job.id}: empty command`)
|
|
125
|
+
const proc = Bun.spawn({ cmd: [cmd, ...args], cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
126
|
+
const code = await proc.exited
|
|
127
|
+
if (code !== 0) {
|
|
128
|
+
const stderr = await new Response(proc.stderr).text()
|
|
129
|
+
throw new Error(`exec job ${job.id} exited with code ${code}: ${stderr.trim() || 'no stderr'}`)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isCronJob(value: unknown): value is CronJob {
|
|
134
|
+
if (typeof value !== 'object' || value === null) return false
|
|
135
|
+
const v = value as { id?: unknown; kind?: unknown }
|
|
136
|
+
if (typeof v.id !== 'string') return false
|
|
137
|
+
return v.kind === 'prompt' || v.kind === 'exec'
|
|
138
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import type { SubagentRegistry } from '@/agent/subagents'
|
|
6
|
+
|
|
7
|
+
import { type CronFile, parseCronFile } from './schema'
|
|
8
|
+
|
|
9
|
+
export { createCronReloadable, type CreateCronReloadableOptions } from './reloadable'
|
|
10
|
+
export {
|
|
11
|
+
createCronConsumer,
|
|
12
|
+
type CreateCronConsumerOptions,
|
|
13
|
+
type CronConsumer,
|
|
14
|
+
type CronConsumerLogger,
|
|
15
|
+
type CronSession,
|
|
16
|
+
} from './consumer'
|
|
17
|
+
export { createScheduler, type JobDiff, type Scheduler, type SchedulerLogger } from './scheduler'
|
|
18
|
+
export { cronFileSchema, cronJobSchema, type CronFile, type CronJob, type ExecJob, type PromptJob } from './schema'
|
|
19
|
+
|
|
20
|
+
const CRON_FILE = 'cron.json'
|
|
21
|
+
|
|
22
|
+
export type LoadCronResult = { ok: true; file: CronFile | null } | { ok: false; reason: string }
|
|
23
|
+
|
|
24
|
+
export type LoadCronOptions = {
|
|
25
|
+
subagents?: SubagentRegistry
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function loadCron(agentDir: string, options: LoadCronOptions = {}): Promise<LoadCronResult> {
|
|
29
|
+
const path = join(agentDir, CRON_FILE)
|
|
30
|
+
if (!existsSync(path)) return { ok: true, file: null }
|
|
31
|
+
|
|
32
|
+
let raw: string
|
|
33
|
+
try {
|
|
34
|
+
raw = await readFile(path, 'utf8')
|
|
35
|
+
} catch (err) {
|
|
36
|
+
return { ok: false, reason: `failed to read cron.json: ${errorMessage(err)}` }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let parsed: unknown
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(raw)
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return { ok: false, reason: `cron.json is not valid JSON: ${errorMessage(err)}` }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const result = parseCronFile(parsed, options.subagents !== undefined ? { subagents: options.subagents } : {})
|
|
47
|
+
if (!result.ok) return { ok: false, reason: result.reason }
|
|
48
|
+
|
|
49
|
+
return { ok: true, file: result.file }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function errorMessage(err: unknown): string {
|
|
53
|
+
return err instanceof Error ? err.message : String(err)
|
|
54
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { SubagentRegistry } from '@/agent/subagents'
|
|
2
|
+
import type { Reloadable, ReloadResult } from '@/reload'
|
|
3
|
+
|
|
4
|
+
import { loadCron } from './index'
|
|
5
|
+
import type { JobDiff, Scheduler } from './scheduler'
|
|
6
|
+
import type { CronJob } from './schema'
|
|
7
|
+
|
|
8
|
+
export type CreateCronReloadableOptions = {
|
|
9
|
+
cwd: string
|
|
10
|
+
scheduler: Scheduler
|
|
11
|
+
// Internal jobs (e.g. dreaming) survive cron.json reloads. The reloadable
|
|
12
|
+
// recomputes them on every reload so config-driven changes propagate too.
|
|
13
|
+
internalJobs?: () => CronJob[]
|
|
14
|
+
// Resolved per reload so plugin reloads (registered earlier) are visible
|
|
15
|
+
// when cron re-validates job.subagent references.
|
|
16
|
+
getSubagents?: () => SubagentRegistry
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createCronReloadable({
|
|
20
|
+
cwd,
|
|
21
|
+
scheduler,
|
|
22
|
+
internalJobs,
|
|
23
|
+
getSubagents,
|
|
24
|
+
}: CreateCronReloadableOptions): Reloadable {
|
|
25
|
+
return {
|
|
26
|
+
scope: 'cron',
|
|
27
|
+
description: 'cron jobs from cron.json',
|
|
28
|
+
reload: async () => doReload({ cwd, scheduler, internalJobs, getSubagents }),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function doReload({
|
|
33
|
+
cwd,
|
|
34
|
+
scheduler,
|
|
35
|
+
internalJobs,
|
|
36
|
+
getSubagents,
|
|
37
|
+
}: CreateCronReloadableOptions): Promise<ReloadResult> {
|
|
38
|
+
const subagents = getSubagents?.()
|
|
39
|
+
const loaded = await loadCron(cwd, subagents !== undefined ? { subagents } : {})
|
|
40
|
+
if (!loaded.ok) {
|
|
41
|
+
return { scope: 'cron', ok: false, reason: loaded.reason }
|
|
42
|
+
}
|
|
43
|
+
const userJobs = loaded.file?.jobs ?? []
|
|
44
|
+
const nextJobs: CronJob[] = [...userJobs, ...(internalJobs?.() ?? [])]
|
|
45
|
+
|
|
46
|
+
let diff: JobDiff
|
|
47
|
+
try {
|
|
48
|
+
diff = scheduler.replaceJobs(nextJobs)
|
|
49
|
+
} catch (err) {
|
|
50
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
51
|
+
return { scope: 'cron', ok: false, reason: `apply failed (schedule unchanged): ${message}` }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
scope: 'cron',
|
|
56
|
+
ok: true,
|
|
57
|
+
summary: formatSummary(diff, nextJobs.length),
|
|
58
|
+
details: diff,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function formatSummary(diff: JobDiff, total: number): string {
|
|
63
|
+
return `${total} jobs (added ${diff.added.length}, removed ${diff.removed.length}, updated ${diff.updated.length}, unchanged ${diff.unchanged.length})`
|
|
64
|
+
}
|