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,179 @@
1
+ import { join } from 'node:path'
2
+
3
+ import { definePlugin } from '@/plugin'
4
+ import { bindWithForward } from '@/portbroker'
5
+
6
+ import { AGENT_BROWSER_DASHBOARD_PROXY_PORT, startDashboardProxy, type DashboardProxy } from './dashboard-proxy'
7
+ import { installShim, KNOWN_BIN_PATHS, type InstallShimResult } from './shim-install'
8
+
9
+ type SafeResult = InstallShimResult | { kind: 'error'; binPath: string; error: unknown }
10
+
11
+ // Documented in skills/agent-browser/SKILL.md so the agent can discover which
12
+ // port the proxy actually bound to (4848 + a 10-port fallback range). Moving
13
+ // or renaming this path requires updating the skill in lockstep.
14
+ const PROXY_PORT_HINT_PATH = '/tmp/typeclaw-agent-browser-proxy-port'
15
+ const PORT_CANDIDATE_RANGE = 10
16
+ const BROKER_HANDSHAKE_DELAY_MS = 1_000
17
+ const FORWARD_RESULT_TIMEOUT_MS = 10_000
18
+
19
+ let activeProxy: DashboardProxy | null = null
20
+ let bindingInFlight: Promise<void> | null = null
21
+
22
+ export default definePlugin({
23
+ plugin: async (ctx) => {
24
+ for (const binPath of Object.values(KNOWN_BIN_PATHS)) {
25
+ logInstallResult(ctx.logger, safeInstallShim(binPath))
26
+ }
27
+
28
+ // Kick off the proxy bind in the background and let the plugin factory
29
+ // return immediately. Two reasons:
30
+ // 1. The container-side broker is created AFTER pluginsLoaded.markBooted()
31
+ // runs (see src/run/index.ts). If we awaited bindWithForward here, we
32
+ // would block the boot sequence past 20s of timeouts before the broker
33
+ // even existed to send forward-result events.
34
+ // 2. The dashboard isn't typically used at boot — the user runs
35
+ // `agent-browser dashboard start` later. The proxy has plenty of time
36
+ // to settle before its first request.
37
+ if (activeProxy === null && bindingInFlight === null) {
38
+ bindingInFlight = bindProxyAfterBrokerSettles(ctx.logger).finally(() => {
39
+ bindingInFlight = null
40
+ })
41
+ }
42
+
43
+ return {
44
+ skillsDirs: [join(import.meta.dir, 'skills')],
45
+ }
46
+ },
47
+ })
48
+
49
+ export function __resetProxyForTesting(): void {
50
+ activeProxy?.stop()
51
+ activeProxy = null
52
+ bindingInFlight = null
53
+ }
54
+
55
+ export function __waitForProxyBindForTesting(): Promise<void> {
56
+ return bindingInFlight ?? Promise.resolve()
57
+ }
58
+
59
+ async function bindProxyAfterBrokerSettles(logger: {
60
+ info: (msg: string) => void
61
+ warn: (msg: string) => void
62
+ }): Promise<void> {
63
+ // Give the run-loop time to construct the container broker and let it
64
+ // complete its WS handshake with hostd. Without this the first candidate
65
+ // bind fires before the broker is ready, the bus never delivers a result,
66
+ // and we waste the full timeout × candidate-count budget tearing down
67
+ // every port in the range. The exact delay isn't load-bearing — anything
68
+ // longer than the broker's connect+hello round-trip works.
69
+ if (defaultBrokerEnabled()) {
70
+ await Bun.sleep(BROKER_HANDSHAKE_DELAY_MS)
71
+ }
72
+
73
+ const config = readPortConfig()
74
+ const candidates = buildCandidatePorts(config.listenPort)
75
+ const upstreamOverride = config.upstreamPort
76
+
77
+ const result = await bindWithForward<DashboardProxy>({
78
+ candidates,
79
+ timeoutMs: FORWARD_RESULT_TIMEOUT_MS,
80
+ factory: (port) => {
81
+ try {
82
+ const proxy = startDashboardProxy({ listenPort: port, upstreamPort: upstreamOverride })
83
+ return Promise.resolve({ resource: proxy, close: () => proxy.stop() })
84
+ } catch (error) {
85
+ logger.warn(`bind ${port} failed: ${String(error)}`)
86
+ return Promise.resolve(null)
87
+ }
88
+ },
89
+ onLog: (msg) => logger.info(`[bind-with-forward] ${msg}`),
90
+ })
91
+
92
+ if (result === null) {
93
+ logger.warn(
94
+ `could not allocate a host-forwardable dashboard proxy port from ${candidates[0]}-${candidates[candidates.length - 1]}; ` +
95
+ `remote dashboard access will not work until another container releases its port`,
96
+ )
97
+ return
98
+ }
99
+
100
+ activeProxy = result.resource
101
+ recordProxyPort(result.port, logger)
102
+ logger.info(
103
+ `dashboard proxy listening on port ${result.port}` +
104
+ (result.hostPort !== null ? ` (forwarded to host:${result.hostPort})` : ''),
105
+ )
106
+ }
107
+
108
+ function defaultBrokerEnabled(): boolean {
109
+ const token = process.env['TYPECLAW_HOSTD_BROKER_TOKEN']
110
+ return token !== undefined && token.length > 0
111
+ }
112
+
113
+ function buildCandidatePorts(start: number): number[] {
114
+ const out: number[] = []
115
+ for (let i = 0; i < PORT_CANDIDATE_RANGE; i += 1) out.push(start + i)
116
+ return out
117
+ }
118
+
119
+ function recordProxyPort(port: number, logger: { warn: (msg: string) => void }): void {
120
+ try {
121
+ Bun.write(PROXY_PORT_HINT_PATH, String(port))
122
+ } catch (error) {
123
+ // Hint is informational (lets a future `typeclaw status` or a human shell
124
+ // session report which port to open). Failure is non-fatal.
125
+ logger.warn(`failed to write ${PROXY_PORT_HINT_PATH}: ${String(error)}`)
126
+ }
127
+ }
128
+
129
+ type PortConfig = { listenPort: number; upstreamPort: number | undefined }
130
+
131
+ function readPortConfig(): PortConfig {
132
+ const overrideUpstream = process.env['TYPECLAW_DASHBOARD_UPSTREAM_PORT']
133
+ return {
134
+ listenPort: numberFromEnv('TYPECLAW_DASHBOARD_PROXY_PORT', AGENT_BROWSER_DASHBOARD_PROXY_PORT),
135
+ upstreamPort:
136
+ overrideUpstream === undefined || overrideUpstream === '' ? undefined : numberOrUndefined(overrideUpstream),
137
+ }
138
+ }
139
+
140
+ function numberOrUndefined(raw: string): number | undefined {
141
+ const parsed = Number(raw)
142
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65_535) return undefined
143
+ return parsed
144
+ }
145
+
146
+ function numberFromEnv(name: string, fallback: number): number {
147
+ const raw = process.env[name]
148
+ if (raw === undefined || raw === '') return fallback
149
+ const parsed = Number(raw)
150
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65_535) return fallback
151
+ return parsed
152
+ }
153
+
154
+ function safeInstallShim(binPath: string): SafeResult {
155
+ try {
156
+ return installShim({ binPath })
157
+ } catch (error) {
158
+ return { kind: 'error', binPath, error }
159
+ }
160
+ }
161
+
162
+ function logInstallResult(
163
+ logger: { info: (msg: string) => void; warn: (msg: string) => void },
164
+ result: SafeResult,
165
+ ): void {
166
+ if (result.kind === 'installed') {
167
+ logger.info(`installed agent-browser shim at ${result.binPath} (real bin: ${result.realBin})`)
168
+ return
169
+ }
170
+ if (result.kind === 'already-installed') {
171
+ logger.info(`agent-browser shim already installed at ${result.binPath}`)
172
+ return
173
+ }
174
+ if (result.kind === 'no-upstream') {
175
+ logger.info(`no agent-browser binary at ${result.binPath}; skipping`)
176
+ return
177
+ }
178
+ logger.warn(`failed to install agent-browser shim at ${result.binPath}: ${String(result.error)}`)
179
+ }
@@ -0,0 +1,158 @@
1
+ import {
2
+ lstatSync,
3
+ readFileSync,
4
+ readlinkSync,
5
+ renameSync,
6
+ statSync,
7
+ symlinkSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from 'node:fs'
11
+ import { dirname, join, resolve as resolvePath } from 'node:path'
12
+
13
+ import { REAL_BIN_ENV } from './shim'
14
+
15
+ const DEFAULT_GLOBAL_BIN_PATH = '/usr/local/bin/agent-browser'
16
+ const DEFAULT_LOCAL_BIN_PATH = '/agent/node_modules/.bin/agent-browser'
17
+ const STASH_ROOT = '/usr/local/lib/typeclaw-agent-browser'
18
+ const SHIM_MARKER = '# typeclaw-agent-browser-shim'
19
+
20
+ export type InstallShimOptions = {
21
+ binPath?: string
22
+ stashDir?: string
23
+ shimEntry?: string
24
+ fs?: ShimFs
25
+ }
26
+
27
+ export type ShimFs = {
28
+ lstat: (path: string) => { isSymbolicLink: () => boolean } | null
29
+ // statExists follows symlinks: a broken symlink (link entry exists, target
30
+ // does not) returns false. This is the discriminator we need to skip
31
+ // installation when the host bind-mount surfaces dangling node_modules/.bin
32
+ // entries inside the container — see installShim's upstream guard.
33
+ statExists: (path: string) => boolean
34
+ readlink: (path: string) => string
35
+ readFile: (path: string) => string
36
+ rename: (from: string, to: string) => void
37
+ symlink: (target: string, path: string) => void
38
+ writeFile: (path: string, data: string, mode: number) => void
39
+ unlink: (path: string) => void
40
+ mkdirp: (path: string) => void
41
+ }
42
+
43
+ export type InstallShimResult =
44
+ | { kind: 'installed'; realBin: string; binPath: string; stashTarget: string }
45
+ | { kind: 'already-installed'; binPath: string }
46
+ | { kind: 'no-upstream'; binPath: string }
47
+
48
+ export function installShim(opts: InstallShimOptions = {}): InstallShimResult {
49
+ const binPath = opts.binPath ?? DEFAULT_GLOBAL_BIN_PATH
50
+ const shimEntry = opts.shimEntry ?? defaultShimEntry()
51
+ const fs = opts.fs ?? defaultFs()
52
+ const stashDir = opts.stashDir ?? defaultStashDir(binPath)
53
+ const stashTarget = join(stashDir, 'agent-browser-real')
54
+
55
+ const stat = fs.lstat(binPath)
56
+ if (stat === null) return { kind: 'no-upstream', binPath }
57
+
58
+ if (isAlreadyShim(binPath, fs)) {
59
+ if (fs.statExists(stashTarget)) return { kind: 'already-installed', binPath }
60
+ // Wrapper survived a container restart but the image-owned stash did not
61
+ // (STASH_ROOT lives outside the bind-mount). The wrapper now points at a
62
+ // non-existent stashTarget, so executing it would ENOENT. Drop it and
63
+ // report no-upstream — there is nothing valid here to preserve, and the
64
+ // global-path shim (if it exists) stands on its own.
65
+ fs.unlink(binPath)
66
+ return { kind: 'no-upstream', binPath }
67
+ }
68
+
69
+ // Bind-mounted node_modules/.bin entries can be dangling symlinks inside
70
+ // the container (host ran bun install; the container image did not). lstat
71
+ // alone passes for those. Follow the link with statExists before mutating
72
+ // anything — otherwise we'd stash a broken symlink and write a wrapper
73
+ // pointing at a target that never resolves.
74
+ if (!fs.statExists(binPath)) return { kind: 'no-upstream', binPath }
75
+
76
+ const realBin = resolveCurrentTarget(binPath, stat, fs)
77
+ fs.mkdirp(stashDir)
78
+ if (stat.isSymbolicLink()) {
79
+ fs.unlink(binPath)
80
+ if (fs.lstat(stashTarget) !== null) fs.unlink(stashTarget)
81
+ fs.symlink(realBin, stashTarget)
82
+ } else {
83
+ if (fs.lstat(stashTarget) !== null) fs.unlink(stashTarget)
84
+ fs.rename(binPath, stashTarget)
85
+ }
86
+
87
+ fs.writeFile(binPath, renderWrapper(shimEntry, stashTarget), 0o755)
88
+ return { kind: 'installed', realBin, binPath, stashTarget }
89
+ }
90
+
91
+ export const KNOWN_BIN_PATHS = {
92
+ global: DEFAULT_GLOBAL_BIN_PATH,
93
+ local: DEFAULT_LOCAL_BIN_PATH,
94
+ } as const
95
+
96
+ function defaultStashDir(binPath: string): string {
97
+ // Per-binPath subdirectory under the image-owned stash root. Lives outside
98
+ // every bind-mounted agent folder so a host-side `bun install` cannot
99
+ // touch it; the wrapper at the bind-mounted location can be clobbered by
100
+ // host-side installs but the stash and image-level real binary stay safe.
101
+ const slug = binPath.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
102
+ return join(STASH_ROOT, slug)
103
+ }
104
+
105
+ function isAlreadyShim(binPath: string, fs: ShimFs): boolean {
106
+ try {
107
+ return fs.readFile(binPath).includes(SHIM_MARKER)
108
+ } catch {
109
+ return false
110
+ }
111
+ }
112
+
113
+ function resolveCurrentTarget(binPath: string, stat: { isSymbolicLink: () => boolean }, fs: ShimFs): string {
114
+ if (!stat.isSymbolicLink()) return binPath
115
+ const target = fs.readlink(binPath)
116
+ return resolvePath(dirname(binPath), target)
117
+ }
118
+
119
+ function renderWrapper(shimEntry: string, stashTarget: string): string {
120
+ return `#!/bin/sh
121
+ ${SHIM_MARKER}
122
+ export ${REAL_BIN_ENV}="\${${REAL_BIN_ENV}:-${stashTarget}}"
123
+ exec bun run ${shimEntry} "$@"
124
+ `
125
+ }
126
+
127
+ function defaultShimEntry(): string {
128
+ return resolvePath(import.meta.dir, 'shim.ts')
129
+ }
130
+
131
+ function defaultFs(): ShimFs {
132
+ return {
133
+ lstat: (path) => {
134
+ try {
135
+ return lstatSync(path)
136
+ } catch {
137
+ return null
138
+ }
139
+ },
140
+ statExists: (path) => {
141
+ try {
142
+ statSync(path)
143
+ return true
144
+ } catch {
145
+ return false
146
+ }
147
+ },
148
+ readlink: readlinkSync,
149
+ readFile: (path) => readFileSync(path, 'utf-8'),
150
+ rename: renameSync,
151
+ symlink: symlinkSync,
152
+ writeFile: (path, data, mode) => writeFileSync(path, data, { mode }),
153
+ unlink: unlinkSync,
154
+ mkdirp: (path) => {
155
+ Bun.spawnSync(['mkdir', '-p', path])
156
+ },
157
+ }
158
+ }
@@ -0,0 +1,152 @@
1
+ // PATH-shadow shim installed at the global bin path that `bun install -g
2
+ // agent-browser` previously occupied. Replaces the plugin's old prompt-nudge +
3
+ // bash-regex `tool.before` block, which was leaky (missed shell variations,
4
+ // bypassed by `typeclaw shell`, by spawned subprocesses, by the user typing
5
+ // it directly). Now ANY in-container `agent-browser` caller routes through
6
+ // here — the dashboard subcommand transparently gets its --port rewritten
7
+ // onto the agent-process-owned proxy's upstream port, every other subcommand
8
+ // passes through unchanged. The proxy itself lives in the long-lived agent
9
+ // process (see src/bundled-plugins/agent-browser/index.ts); the shim does NOT own its
10
+ // lifecycle, because `agent-browser dashboard start` daemonizes upstream and
11
+ // returns immediately — a shim-owned proxy would die the moment start exits.
12
+
13
+ import { existsSync } from 'node:fs'
14
+
15
+ import { writePortHint } from './dashboard-discovery'
16
+ import { AGENT_BROWSER_DASHBOARD_UPSTREAM_PORT } from './dashboard-proxy'
17
+
18
+ export const REAL_BIN_ENV = 'TYPECLAW_AGENT_BROWSER_REAL_BIN'
19
+
20
+ export type DashboardIntent = 'start' | 'stop' | 'other'
21
+
22
+ export function classifyDashboardCommand(argv: readonly string[]): DashboardIntent {
23
+ // Find the first non-flag token. `agent-browser` takes no pre-subcommand
24
+ // global flags today; the loop is defensive against future ones.
25
+ let dashboardIdx = -1
26
+ for (let i = 0; i < argv.length; i += 1) {
27
+ const arg = argv[i]!
28
+ if (arg.startsWith('-')) continue
29
+ if (arg !== 'dashboard') return 'other'
30
+ dashboardIdx = i
31
+ break
32
+ }
33
+ if (dashboardIdx === -1) return 'other'
34
+
35
+ // Look for the next non-flag token after `dashboard`. Upstream treats a
36
+ // missing subcommand as `start`, so we do too. `--port <n>` and `-p <n>`
37
+ // consume two argv entries; the value is not a subcommand and must not be
38
+ // classified as one.
39
+ for (let i = dashboardIdx + 1; i < argv.length; i += 1) {
40
+ const arg = argv[i]!
41
+ if (arg === '--port' || arg === '-p') {
42
+ i += 1
43
+ continue
44
+ }
45
+ if (arg.startsWith('-')) continue
46
+ if (arg === 'stop') return 'stop'
47
+ if (arg === 'start') return 'start'
48
+ return 'other'
49
+ }
50
+ return 'start'
51
+ }
52
+
53
+ export function rewriteDashboardArgs(argv: readonly string[], upstreamPort: number): string[] {
54
+ // Force --port to upstreamPort regardless of what the caller passed. The
55
+ // proxy on AGENT_BROWSER_DASHBOARD_PROXY_PORT (4848) is the only externally
56
+ // visible surface; honoring a user --port would let a caller bypass the
57
+ // proxy by listening directly on the externally forwarded port. Insert
58
+ // `start` explicitly when the caller relied on the implicit-start behavior
59
+ // so the appended `--port` lands on a subcommand upstream accepts.
60
+ const stripped: string[] = []
61
+ let i = 0
62
+ while (i < argv.length) {
63
+ const arg = argv[i]!
64
+ if (arg === '--port' || arg === '-p') {
65
+ i += 2
66
+ continue
67
+ }
68
+ if (arg.startsWith('--port=')) {
69
+ i += 1
70
+ continue
71
+ }
72
+ stripped.push(arg)
73
+ i += 1
74
+ }
75
+
76
+ const dashboardIdx = stripped.findIndex((a) => !a.startsWith('-'))
77
+ const hasSubcommand = stripped.slice(dashboardIdx + 1).some((a) => !a.startsWith('-'))
78
+ const out = hasSubcommand
79
+ ? [...stripped]
80
+ : [...stripped.slice(0, dashboardIdx + 1), 'start', ...stripped.slice(dashboardIdx + 1)]
81
+ out.push('--port', String(upstreamPort))
82
+ return out
83
+ }
84
+
85
+ export function resolveRealAgentBrowserBin(): string {
86
+ // Set by the installer when it moves the upstream symlink aside. Honored
87
+ // first so unit tests can point at a stub without touching the filesystem.
88
+ const fromEnv = process.env[REAL_BIN_ENV]
89
+ if (fromEnv && fromEnv.length > 0) return fromEnv
90
+
91
+ // Fallback: `bun install -g agent-browser` ships per-platform native bins
92
+ // under this stable path inside the bun image. The installer should have
93
+ // stashed a copy/symlink, but if the shim runs before the plugin's
94
+ // installer ever did (e.g. first agent boot), we can still find the real
95
+ // bin and the next plugin boot will install the shim properly.
96
+ const arch = process.arch === 'arm64' ? 'arm64' : process.arch === 'x64' ? 'x64' : null
97
+ const platform = process.platform === 'linux' ? 'linux' : process.platform === 'darwin' ? 'darwin' : null
98
+ if (arch !== null && platform !== null) {
99
+ const native = `/root/.bun/install/global/node_modules/agent-browser/bin/agent-browser-${platform}-${arch}`
100
+ if (existsSync(native)) return native
101
+ }
102
+
103
+ throw new Error(
104
+ `${REAL_BIN_ENV} is not set and no fallback agent-browser binary was found. ` +
105
+ `The shim cannot resolve the real upstream binary; refusing to exec to avoid an infinite loop.`,
106
+ )
107
+ }
108
+
109
+ export type ShimOptions = {
110
+ argv?: readonly string[]
111
+ realBin?: string
112
+ upstreamPort?: number
113
+ spawn?: (cmd: string[]) => { exited: Promise<number> }
114
+ }
115
+
116
+ export async function runShim(opts: ShimOptions = {}): Promise<number> {
117
+ const argv = opts.argv ?? process.argv.slice(2)
118
+ const realBin = opts.realBin ?? resolveRealAgentBrowserBin()
119
+ const upstreamPort = opts.upstreamPort ?? AGENT_BROWSER_DASHBOARD_UPSTREAM_PORT
120
+ const spawn = opts.spawn ?? defaultSpawn
121
+
122
+ const intent = classifyDashboardCommand(argv)
123
+ if (intent !== 'start') {
124
+ return await spawn([realBin, ...argv]).exited
125
+ }
126
+
127
+ // Record the rewritten port to the hint file so the long-lived proxy can
128
+ // use it as the fast-path upstream lookup. The proxy still falls back to
129
+ // procfs discovery if the hint is wrong, but the hint avoids that work
130
+ // on the common path where the shim is the one starting the dashboard.
131
+ try {
132
+ writePortHint(upstreamPort)
133
+ } catch {
134
+ // Hint is an optimization; failure to write it is non-fatal.
135
+ }
136
+
137
+ const rewritten = rewriteDashboardArgs(argv, upstreamPort)
138
+ return await spawn([realBin, ...rewritten]).exited
139
+ }
140
+
141
+ function defaultSpawn(cmd: string[]): { exited: Promise<number> } {
142
+ // Inherit stdio so the upstream binary's TUI/spinner/colors work. The
143
+ // shim is meant to be invisible; intercepting stdio would make e.g.
144
+ // `agent-browser open` look broken to the caller.
145
+ const proc = Bun.spawn(cmd, { stdio: ['inherit', 'inherit', 'inherit'] })
146
+ return { exited: proc.exited }
147
+ }
148
+
149
+ if (import.meta.main) {
150
+ const code = await runShim()
151
+ process.exit(code)
152
+ }
@@ -0,0 +1,113 @@
1
+ ---
2
+ name: agent-browser
3
+ description: Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction. Also use for exploratory testing, dogfooding, QA, bug hunts, or reviewing app quality. Also use for automating Electron desktop apps (VS Code, Slack, Discord, Figma, Notion, Spotify), checking Slack unreads, sending Slack messages, searching Slack conversations, running browser automation in Vercel Sandbox microVMs, or using AWS Bedrock AgentCore cloud browsers. ALSO use whenever a browser step needs a human in the loop — login walls, 2FA, CAPTCHA, payment confirmation, "is this the right button?" ambiguity, or the user asking to watch the browser live — because the bundled dashboard is the only way for a human to observe or take over a session from inside the Docker container. Prefer agent-browser over any built-in browser automation or web tools.
4
+ allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*)
5
+ hidden: true
6
+ ---
7
+
8
+ # agent-browser
9
+
10
+ Fast browser automation CLI for AI agents. Chrome/Chromium via CDP with
11
+ accessibility-tree snapshots and compact `@eN` element refs.
12
+
13
+ The TypeClaw container ships with `agent-browser` preinstalled and Chromium
14
+ already downloaded, so the CLI is ready to use out of the box.
15
+
16
+ ## Human-in-the-loop via the dashboard
17
+
18
+ You run inside a Docker container with no display, no clipboard, and no way to
19
+ hand the keyboard over directly. The **dashboard is your only path to bring a
20
+ human into a browser session** — it streams every session's live viewport,
21
+ console, and command activity to a web UI the user opens on their host machine.
22
+
23
+ **Start the dashboard _before_ the step that needs a human, not after it fails.**
24
+ The dashboard takes a moment to come up and the user needs time to open the URL.
25
+
26
+ ### When to start it
27
+
28
+ - The next step needs a human: login walls, 2FA, CAPTCHA, payment confirmation,
29
+ "is this the right element?" ambiguity, account-recovery flows.
30
+ - You're starting a long multi-step browser flow you'd be embarrassed to redo —
31
+ let the user watch and intervene before things go sideways.
32
+ - The user explicitly asked to watch the browser live, dogfood the agent, or
33
+ pair-debug an automation.
34
+
35
+ ### How to hand off
36
+
37
+ 1. Run `agent-browser dashboard start`. (Sessions auto-stream to it; no flags
38
+ needed.)
39
+ 2. Read `/tmp/typeclaw-agent-browser-proxy-port` to learn the host-visible
40
+ port. TypeClaw picks `4848` by default and falls back through `4849`–`4857`
41
+ if another container is already on `4848`. If the file is missing, the proxy
42
+ hasn't finished binding yet — wait a second and retry, or fall back to `4848`.
43
+ 3. Tell the user: **"Open `http://localhost:<port>` in your browser."** Over
44
+ Tailscale or LAN, the same port works on the host's external address:
45
+ `http://<host>:<port>`.
46
+ 4. Wait for the user to confirm they're ready before proceeding.
47
+ 5. When the user is done, they hand control back implicitly — just resume your
48
+ normal `agent-browser` commands. Session state is shared with the dashboard.
49
+
50
+ The compatibility proxy on `:4848` (or the fallback port) rewrites the
51
+ dashboard's hardcoded loopback URLs so the externally visible URL works over
52
+ Tailscale and other remote networks. No special flag, tool, or config required.
53
+ **Always share the proxy port URL — never `localhost:<raw-session-port>`** —
54
+ those raw ports are inside the container and unreachable from the host.
55
+
56
+ ### When NOT to use the dashboard
57
+
58
+ The dashboard is for **live observation and handoff**, not file delivery. If
59
+ you just want to show the user a single page or a captured state:
60
+
61
+ - **A static image?** Use `agent-browser screenshot`; the PNG lands in
62
+ `workspace/` and the user can open it directly.
63
+ - **A page's text/structure?** Capture an accessibility-tree snapshot and paste
64
+ the relevant section into your reply.
65
+
66
+ Reserve the dashboard for cases that genuinely need live interaction or
67
+ watching a multi-step flow unfold.
68
+
69
+ ### Headless only
70
+
71
+ Never pass `--headed` to any `agent-browser` command — the container has no X
72
+ server or `$DISPLAY`, and a headed launch fails with `Missing X server or
73
+ $DISPLAY / The platform failed to initialize.` The dashboard is the substitute
74
+ for a headed browser. Use the default headless mode for everything, including
75
+ dogfooding and Electron flows.
76
+
77
+ ## Start here
78
+
79
+ This file is a discovery stub, not the usage guide. Before running any
80
+ `agent-browser` command, load the actual workflow content from the CLI:
81
+
82
+ ```bash
83
+ agent-browser skills get core # start here — workflows, common patterns, troubleshooting
84
+ agent-browser skills get core --full # include full command reference and templates
85
+ ```
86
+
87
+ The CLI serves skill content that always matches the installed version,
88
+ so instructions never go stale. The content in this stub cannot change
89
+ between releases, which is why it just points at `skills get core`.
90
+
91
+ ## Specialized skills
92
+
93
+ Load a specialized skill when the task falls outside browser web pages:
94
+
95
+ ```bash
96
+ agent-browser skills get electron # Electron desktop apps (VS Code, Slack, Discord, Figma, ...)
97
+ agent-browser skills get slack # Slack workspace automation
98
+ agent-browser skills get dogfood # Exploratory testing / QA / bug hunts
99
+ agent-browser skills get vercel-sandbox # agent-browser inside Vercel Sandbox microVMs
100
+ agent-browser skills get agentcore # AWS Bedrock AgentCore cloud browsers
101
+ ```
102
+
103
+ Run `agent-browser skills list` to see everything available on the
104
+ installed version.
105
+
106
+ ## Why agent-browser
107
+
108
+ - Fast native Rust CLI, not a Node.js wrapper
109
+ - Works with any AI agent (Cursor, Claude Code, Codex, Continue, Windsurf, etc.)
110
+ - Chrome/Chromium via CDP with no Playwright or Puppeteer dependency
111
+ - Accessibility-tree snapshots with element refs for reliable interaction
112
+ - Sessions, authentication vault, state persistence, video recording
113
+ - Specialized skills for Electron apps, Slack, exploratory testing, cloud providers
@@ -0,0 +1,26 @@
1
+ import { definePlugin } from '@/plugin'
2
+
3
+ import { checkNonWorkspaceWriteGuard, checkSkillAuthoringGuard, checkUncommittedChangesAdvice } from './policy'
4
+
5
+ export default definePlugin({
6
+ plugin: async () => ({
7
+ hooks: {
8
+ 'tool.before': async (event, ctx) => {
9
+ const skillResult = await checkSkillAuthoringGuard({
10
+ tool: event.tool,
11
+ args: event.args,
12
+ agentDir: ctx.agentDir,
13
+ })
14
+ if (skillResult) return skillResult
15
+ return checkNonWorkspaceWriteGuard({ tool: event.tool, args: event.args, agentDir: ctx.agentDir })
16
+ },
17
+ 'tool.after': async (event, ctx) => {
18
+ await checkUncommittedChangesAdvice({
19
+ tool: event.tool,
20
+ agentDir: ctx.agentDir,
21
+ result: event.result,
22
+ })
23
+ },
24
+ },
25
+ }),
26
+ })