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.
Files changed (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/auth.schema.json +63 -0
  4. package/cron.schema.json +96 -0
  5. package/package.json +72 -0
  6. package/scripts/emit-base-dockerfile.ts +5 -0
  7. package/scripts/generate-schema.ts +34 -0
  8. package/secrets.schema.json +63 -0
  9. package/src/agent/auth.ts +119 -0
  10. package/src/agent/compaction.ts +35 -0
  11. package/src/agent/git-nudge.ts +95 -0
  12. package/src/agent/index.ts +451 -0
  13. package/src/agent/plugin-tools.ts +269 -0
  14. package/src/agent/reload-tool.ts +71 -0
  15. package/src/agent/self.ts +45 -0
  16. package/src/agent/session-origin.ts +288 -0
  17. package/src/agent/subagents.ts +253 -0
  18. package/src/agent/system-prompt.ts +68 -0
  19. package/src/agent/tools/channel-fetch-attachment.ts +118 -0
  20. package/src/agent/tools/channel-history.ts +119 -0
  21. package/src/agent/tools/channel-reply.ts +182 -0
  22. package/src/agent/tools/channel-send.ts +212 -0
  23. package/src/agent/tools/ddg.ts +218 -0
  24. package/src/agent/tools/restart.ts +122 -0
  25. package/src/agent/tools/stream-snapshot.ts +181 -0
  26. package/src/agent/tools/webfetch/fetch.ts +102 -0
  27. package/src/agent/tools/webfetch/index.ts +1 -0
  28. package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
  29. package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
  30. package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
  31. package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
  32. package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
  33. package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
  34. package/src/agent/tools/webfetch/tool.ts +281 -0
  35. package/src/agent/tools/webfetch/types.ts +33 -0
  36. package/src/agent/tools/websearch.ts +96 -0
  37. package/src/agent/tools/wikipedia.ts +52 -0
  38. package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
  39. package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
  40. package/src/bundled-plugins/agent-browser/index.ts +179 -0
  41. package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
  42. package/src/bundled-plugins/agent-browser/shim.ts +152 -0
  43. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
  44. package/src/bundled-plugins/guard/index.ts +26 -0
  45. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
  46. package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
  47. package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
  48. package/src/bundled-plugins/guard/policy.ts +18 -0
  49. package/src/bundled-plugins/memory/README.md +71 -0
  50. package/src/bundled-plugins/memory/append-tool.ts +84 -0
  51. package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
  52. package/src/bundled-plugins/memory/dreaming.ts +470 -0
  53. package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
  54. package/src/bundled-plugins/memory/index.ts +238 -0
  55. package/src/bundled-plugins/memory/load-memory.ts +122 -0
  56. package/src/bundled-plugins/memory/memory-logger.ts +257 -0
  57. package/src/bundled-plugins/memory/secret-detector.ts +49 -0
  58. package/src/bundled-plugins/memory/watermark.ts +15 -0
  59. package/src/bundled-plugins/security/index.ts +35 -0
  60. package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
  61. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
  62. package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
  63. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
  64. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
  65. package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
  66. package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
  67. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
  68. package/src/bundled-plugins/security/policy.ts +9 -0
  69. package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
  70. package/src/channels/adapters/discord-bot-classify.ts +148 -0
  71. package/src/channels/adapters/discord-bot.ts +640 -0
  72. package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
  73. package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
  74. package/src/channels/adapters/kakaotalk-classify.ts +77 -0
  75. package/src/channels/adapters/kakaotalk.ts +622 -0
  76. package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
  77. package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
  78. package/src/channels/adapters/slack-bot-classify.ts +213 -0
  79. package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
  80. package/src/channels/adapters/slack-bot-time.ts +10 -0
  81. package/src/channels/adapters/slack-bot.ts +881 -0
  82. package/src/channels/adapters/telegram-bot-classify.ts +155 -0
  83. package/src/channels/adapters/telegram-bot-format.ts +309 -0
  84. package/src/channels/adapters/telegram-bot.ts +604 -0
  85. package/src/channels/engagement.ts +227 -0
  86. package/src/channels/index.ts +21 -0
  87. package/src/channels/manager.ts +292 -0
  88. package/src/channels/membership-cache.ts +116 -0
  89. package/src/channels/membership-from-history.ts +53 -0
  90. package/src/channels/membership.ts +30 -0
  91. package/src/channels/participants.ts +47 -0
  92. package/src/channels/persistence.ts +209 -0
  93. package/src/channels/reloadable.ts +28 -0
  94. package/src/channels/router.ts +1570 -0
  95. package/src/channels/schema.ts +273 -0
  96. package/src/channels/types.ts +160 -0
  97. package/src/cli/channel.ts +403 -0
  98. package/src/cli/compose-status.ts +95 -0
  99. package/src/cli/compose.ts +240 -0
  100. package/src/cli/hostd.ts +163 -0
  101. package/src/cli/index.ts +27 -0
  102. package/src/cli/init.ts +592 -0
  103. package/src/cli/logs.ts +38 -0
  104. package/src/cli/reload.ts +68 -0
  105. package/src/cli/restart.ts +66 -0
  106. package/src/cli/run.ts +77 -0
  107. package/src/cli/shell.ts +33 -0
  108. package/src/cli/start.ts +57 -0
  109. package/src/cli/status.ts +178 -0
  110. package/src/cli/stop.ts +31 -0
  111. package/src/cli/tui.ts +35 -0
  112. package/src/cli/ui.ts +110 -0
  113. package/src/commands/index.ts +74 -0
  114. package/src/compose/discover.ts +43 -0
  115. package/src/compose/index.ts +25 -0
  116. package/src/compose/logs.ts +162 -0
  117. package/src/compose/restart.ts +69 -0
  118. package/src/compose/start.ts +62 -0
  119. package/src/compose/status.ts +28 -0
  120. package/src/compose/stop.ts +43 -0
  121. package/src/config/config.ts +424 -0
  122. package/src/config/index.ts +25 -0
  123. package/src/config/providers.ts +234 -0
  124. package/src/config/reloadable.ts +47 -0
  125. package/src/container/index.ts +27 -0
  126. package/src/container/logs.ts +37 -0
  127. package/src/container/port.ts +137 -0
  128. package/src/container/shared.ts +290 -0
  129. package/src/container/shell.ts +58 -0
  130. package/src/container/start.ts +670 -0
  131. package/src/container/status.ts +76 -0
  132. package/src/container/stop.ts +120 -0
  133. package/src/container/verify-running.ts +149 -0
  134. package/src/cron/consumer.ts +138 -0
  135. package/src/cron/index.ts +54 -0
  136. package/src/cron/reloadable.ts +64 -0
  137. package/src/cron/scheduler.ts +200 -0
  138. package/src/cron/schema.ts +96 -0
  139. package/src/hostd/client.ts +113 -0
  140. package/src/hostd/daemon.ts +587 -0
  141. package/src/hostd/index.ts +25 -0
  142. package/src/hostd/paths.ts +82 -0
  143. package/src/hostd/portbroker-manager.ts +101 -0
  144. package/src/hostd/protocol.ts +48 -0
  145. package/src/hostd/spawn.ts +224 -0
  146. package/src/hostd/supervisor.ts +60 -0
  147. package/src/hostd/tailscale.ts +172 -0
  148. package/src/hostd/version.ts +115 -0
  149. package/src/init/dockerfile.ts +327 -0
  150. package/src/init/ensure-deps.ts +152 -0
  151. package/src/init/gitignore.ts +46 -0
  152. package/src/init/hatching.ts +60 -0
  153. package/src/init/index.ts +786 -0
  154. package/src/init/kakaotalk-auth.ts +114 -0
  155. package/src/init/models-dev.ts +130 -0
  156. package/src/init/oauth-login.ts +74 -0
  157. package/src/init/packagejson.ts +94 -0
  158. package/src/init/paths.ts +2 -0
  159. package/src/init/run-bun-install.ts +20 -0
  160. package/src/markdown/chunk.ts +299 -0
  161. package/src/markdown/index.ts +1 -0
  162. package/src/plugin/context.ts +40 -0
  163. package/src/plugin/define.ts +35 -0
  164. package/src/plugin/hooks.ts +204 -0
  165. package/src/plugin/index.ts +63 -0
  166. package/src/plugin/loader.ts +111 -0
  167. package/src/plugin/manager.ts +136 -0
  168. package/src/plugin/registry.ts +145 -0
  169. package/src/plugin/skills.ts +62 -0
  170. package/src/plugin/types.ts +172 -0
  171. package/src/portbroker/bind-with-forward.ts +102 -0
  172. package/src/portbroker/container-server.ts +305 -0
  173. package/src/portbroker/forward-result-bus.ts +36 -0
  174. package/src/portbroker/hostd-client.ts +443 -0
  175. package/src/portbroker/index.ts +33 -0
  176. package/src/portbroker/policy.ts +24 -0
  177. package/src/portbroker/proc-net-tcp.ts +72 -0
  178. package/src/portbroker/protocol.ts +39 -0
  179. package/src/reload/client.ts +59 -0
  180. package/src/reload/index.ts +3 -0
  181. package/src/reload/registry.ts +60 -0
  182. package/src/reload/types.ts +13 -0
  183. package/src/run/bundled-plugins.ts +24 -0
  184. package/src/run/channel-session-factory.ts +105 -0
  185. package/src/run/index.ts +432 -0
  186. package/src/run/plugin-runtime.ts +43 -0
  187. package/src/run/schema-with-plugins.ts +14 -0
  188. package/src/secrets/index.ts +13 -0
  189. package/src/secrets/migrate.ts +95 -0
  190. package/src/secrets/schema.ts +75 -0
  191. package/src/secrets/storage.ts +231 -0
  192. package/src/server/index.ts +436 -0
  193. package/src/sessions/index.ts +23 -0
  194. package/src/shared/index.ts +9 -0
  195. package/src/shared/local-time.ts +21 -0
  196. package/src/shared/protocol.ts +25 -0
  197. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
  198. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
  199. package/src/skills/typeclaw-config/SKILL.md +643 -0
  200. package/src/skills/typeclaw-cron/SKILL.md +159 -0
  201. package/src/skills/typeclaw-git/SKILL.md +89 -0
  202. package/src/skills/typeclaw-memory/SKILL.md +174 -0
  203. package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
  204. package/src/skills/typeclaw-plugins/SKILL.md +594 -0
  205. package/src/skills/typeclaw-skills/SKILL.md +246 -0
  206. package/src/stream/broker.ts +161 -0
  207. package/src/stream/index.ts +16 -0
  208. package/src/stream/types.ts +69 -0
  209. package/src/tui/client.ts +45 -0
  210. package/src/tui/format.ts +317 -0
  211. package/src/tui/index.ts +225 -0
  212. package/src/tui/theme.ts +41 -0
  213. 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
+ }