typeclaw 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,7 +43,7 @@ The bundled `memory` plugin turns lived experience into reusable knowledge. No m
43
43
 
44
44
  1. **Observe.** After every idle turn, a `memory-logger` subagent reads the transcript and appends notable fragments to `memory/yyyy-MM-dd.md`. Cheap, frequent, lossy by design.
45
45
  2. **Dream.** On a cron schedule (default 4am), a `dreaming` subagent consolidates daily streams into `MEMORY.md`, and — when it spots a procedure worth remembering — writes it as **muscle memory**: a new skill at `memory/skills/<name>/SKILL.md`.
46
- 3. **Apply.** Tomorrow's prompt sees the updated `MEMORY.md`. Muscle-memory skills sit alongside bundled and user-installed ones, loaded on demand. Every dream is `git commit -m Dream`'d, so growth is auditable.
46
+ 3. **Apply.** Tomorrow's prompt sees the updated `MEMORY.md`. Muscle-memory skills sit alongside bundled and user-installed ones, loaded on demand. Every dream is committed with a one-line summary — e.g. `dream: 3 fragments + new skill 'pr-review' 🔮` — so growth is auditable.
47
47
 
48
48
  See [`src/bundled-plugins/memory/README.md`](./src/bundled-plugins/memory/README.md) for the full contract.
49
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -27,14 +27,14 @@ All fields are **restart-required** — the plugin reads them once at boot.
27
27
 
28
28
  ## What it contributes
29
29
 
30
- | Kind | Name | Notes |
31
- | -------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
32
- | Subagent | `memory-logger` | Reads a parent transcript past a watermark and appends fragments to `memory/<today>.md`. Coalesced per `agentDir`; the plugin chains spawn calls onto a per-agent Promise so two concurrent channel sessions never race on the same daily stream file. |
33
- | Subagent | `dreaming` | Reads `MEMORY.md` plus undreamed daily-stream tails, rewrites `MEMORY.md`, optionally writes muscle-memory skills under `memory/skills/<name>/SKILL.md`, advances the per-day watermark, and `git commit -m Dream` the result. Coalesced per `agentDir`. |
34
- | Cron job | `__plugin_memory_dreaming` | `kind: 'prompt'`, `subagent: 'dreaming'`, scheduled per `memory.dreaming.schedule`. |
35
- | Hook | `session.prompt` | Appends the rendered memory section (`# Memory`, `MEMORY.md`, undreamed stream tails) to `event.prompt`. |
36
- | Hook | `session.idle` | Per-session debouncer with size-based ceiling. Resets a `setTimeout(idleMs)` on every event; on fire, calls `ctx.spawnSubagent('memory-logger', ...)`. Also `fs.stat`s the transcript on every event and spawns immediately when growth since the last run reaches `bufferBytes`. |
37
- | Hook | `session.end` | Cancels the debounce timer and immediately spawns `memory-logger` (so the final transcript is captured even when the user disconnects right away). |
30
+ | Kind | Name | Notes |
31
+ | -------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
32
+ | Subagent | `memory-logger` | Reads a parent transcript past a watermark and appends fragments to `memory/<today>.md`. Coalesced per `agentDir`; the plugin chains spawn calls onto a per-agent Promise so two concurrent channel sessions never race on the same daily stream file. |
33
+ | Subagent | `dreaming` | Reads `MEMORY.md` plus undreamed daily-stream tails, rewrites `MEMORY.md`, optionally writes muscle-memory skills under `memory/skills/<name>/SKILL.md`, advances the per-day watermark, and commits the result with a summary message (`dream: <summary> <emoji>`, e.g. `dream: 3 fragments + new skill 'pr-review' 🔮`). Coalesced per `agentDir`. |
34
+ | Cron job | `__plugin_memory_dreaming` | `kind: 'prompt'`, `subagent: 'dreaming'`, scheduled per `memory.dreaming.schedule`. |
35
+ | Hook | `session.prompt` | Appends the rendered memory section (`# Memory`, `MEMORY.md`, undreamed stream tails) to `event.prompt`. |
36
+ | Hook | `session.idle` | Per-session debouncer with size-based ceiling. Resets a `setTimeout(idleMs)` on every event; on fire, calls `ctx.spawnSubagent('memory-logger', ...)`. Also `fs.stat`s the transcript on every event and spawns immediately when growth since the last run reaches `bufferBytes`. |
37
+ | Hook | `session.end` | Cancels the debounce timer and immediately spawns `memory-logger` (so the final transcript is captured even when the user disconnects right away). |
38
38
 
39
39
  ## Files on disk
40
40
 
@@ -183,8 +183,10 @@ export async function commitMemorySnapshot(cwd: string): Promise<void> {
183
183
  return
184
184
  }
185
185
 
186
+ const message = await buildCommitMessage(bun, cwd, staged)
187
+
186
188
  const commit = bun.spawn({
187
- cmd: ['git', 'commit', '-m', 'Dream', '--only', '--', ...staged],
189
+ cmd: ['git', 'commit', '-m', message, '--only', '--', ...staged],
188
190
  cwd,
189
191
  stdout: 'pipe',
190
192
  stderr: 'pipe',
@@ -194,6 +196,120 @@ export async function commitMemorySnapshot(cwd: string): Promise<void> {
194
196
  await applySkipWorktree(bun, cwd)
195
197
  }
196
198
 
199
+ // Pool of emojis sampled into every dream commit. The pool is small and
200
+ // thematically coherent (sleep + cognition) so `git log --oneline` reads like a
201
+ // dream journal. Exported for tests.
202
+ export const DREAM_EMOJI_POOL = ['💤', '🌙', '⭐', '🛌', '😴', '🧠', '💭', '🔮'] as const
203
+ export type DreamEmoji = (typeof DREAM_EMOJI_POOL)[number]
204
+
205
+ // Random pick is deliberate (not seeded). Independent draw per commit gives the
206
+ // log surface maximum visual variety; correctness does not depend on the
207
+ // emoji.
208
+ function pickDreamEmoji(): DreamEmoji {
209
+ const i = Math.floor(Math.random() * DREAM_EMOJI_POOL.length)
210
+ return DREAM_EMOJI_POOL[i] ?? DREAM_EMOJI_POOL[0]
211
+ }
212
+
213
+ // Build `dream: <summary> <emoji>` from what is actually staged in the
214
+ // snapshot. The summary is derived from the staged diff (ground truth of what
215
+ // is being committed), not from the handler's intent — so a partial commit
216
+ // reports honestly.
217
+ //
218
+ // Classification:
219
+ // - `N fragments` when daily-stream files (memory/yyyy-MM-dd.md) added lines
220
+ // - `+ new skill 'x'` / `+ N new skills` when memory/skills/<name>/SKILL.md
221
+ // paths are newly added in this commit (status A, not M)
222
+ // - `MEMORY.md only` when only MEMORY.md changed
223
+ // - `watermarks only` as the fallback (e.g. only .dreaming-state.json moved)
224
+ export async function buildCommitMessage(
225
+ bun: { spawn: typeof Bun.spawn },
226
+ cwd: string,
227
+ staged: string[],
228
+ emojiPicker: () => DreamEmoji = pickDreamEmoji,
229
+ ): Promise<string> {
230
+ const summary = await buildDreamSummary(bun, cwd, staged)
231
+ return `dream: ${summary} ${emojiPicker()}`
232
+ }
233
+
234
+ const STREAM_FILE_RELATIVE = /^memory\/\d{4}-\d{2}-\d{2}\.md$/
235
+ const SKILL_FILE_RELATIVE = /^memory\/skills\/([^/]+)\/SKILL\.md$/
236
+
237
+ async function buildDreamSummary(bun: { spawn: typeof Bun.spawn }, cwd: string, staged: string[]): Promise<string> {
238
+ // numstat: `<added>\t<deleted>\t<path>` per line. Use NUL-terminated so paths
239
+ // with whitespace round-trip; -z switches the record separator to NUL.
240
+ const numstat = bun.spawn({
241
+ cmd: ['git', 'diff', '--cached', '--numstat', '-z', '--', ...staged],
242
+ cwd,
243
+ stdout: 'pipe',
244
+ stderr: 'pipe',
245
+ })
246
+ const raw = await new Response(numstat.stdout).text()
247
+ if ((await numstat.exited) !== 0) return 'snapshot'
248
+
249
+ let fragmentLines = 0
250
+ let touchedMemoryMd = false
251
+ for (const record of raw.split('\0')) {
252
+ if (record.length === 0) continue
253
+ // Each record is `<added>\t<deleted>\t<path>`; binary files report `-`
254
+ // instead of integers — treat those as 0 since memory artifacts are text.
255
+ const [addedStr = '', , path = ''] = record.split('\t')
256
+ const added = Number.parseInt(addedStr, 10)
257
+ if (!Number.isFinite(added)) continue
258
+ if (path === 'MEMORY.md') {
259
+ touchedMemoryMd = true
260
+ } else if (STREAM_FILE_RELATIVE.test(path)) {
261
+ fragmentLines += added
262
+ }
263
+ }
264
+
265
+ // Newly-added muscle-memory skills (status A). Refinements (status M) are
266
+ // not announced — they ride under the fragment count.
267
+ const newSkills = await listNewlyAddedSkills(bun, cwd, staged)
268
+
269
+ const parts: string[] = []
270
+ if (fragmentLines > 0) {
271
+ parts.push(`${fragmentLines} fragment${fragmentLines === 1 ? '' : 's'}`)
272
+ } else if (touchedMemoryMd && newSkills.length === 0) {
273
+ parts.push('MEMORY.md only')
274
+ }
275
+ if (newSkills.length === 1) {
276
+ parts.push(`new skill '${newSkills[0]}'`)
277
+ } else if (newSkills.length > 1) {
278
+ parts.push(`${newSkills.length} new skills`)
279
+ }
280
+
281
+ if (parts.length === 0) return 'watermarks only'
282
+ return parts.join(' + ')
283
+ }
284
+
285
+ async function listNewlyAddedSkills(
286
+ bun: { spawn: typeof Bun.spawn },
287
+ cwd: string,
288
+ staged: string[],
289
+ ): Promise<string[]> {
290
+ const proc = bun.spawn({
291
+ cmd: ['git', 'diff', '--cached', '--name-status', '-z', '--', ...staged],
292
+ cwd,
293
+ stdout: 'pipe',
294
+ stderr: 'pipe',
295
+ })
296
+ const raw = await new Response(proc.stdout).text()
297
+ if ((await proc.exited) !== 0) return []
298
+
299
+ // `--name-status -z` interleaves status and path as separate NUL records:
300
+ // `A\0path\0M\0other\0...`. Pair them up.
301
+ const tokens = raw.split('\0').filter((t) => t.length > 0)
302
+ const names: string[] = []
303
+ for (let i = 0; i + 1 < tokens.length; i += 2) {
304
+ const status = tokens[i] ?? ''
305
+ const path = tokens[i + 1] ?? ''
306
+ if (status !== 'A') continue
307
+ const match = SKILL_FILE_RELATIVE.exec(path)
308
+ if (match) names.push(match[1] ?? '')
309
+ }
310
+ return names.filter((n) => n.length > 0)
311
+ }
312
+
197
313
  async function listTrackedSnapshotFiles(bun: { spawn: typeof Bun.spawn }, cwd: string): Promise<string[]> {
198
314
  const ls = bun.spawn({
199
315
  cmd: ['git', 'ls-files', '-z', '--', ...SNAPSHOT_PATHS],
package/src/cli/init.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  findAgentDir,
14
14
  isDirectoryNonEmpty,
15
15
  isHatched,
16
+ readExistingProviderApiKey,
16
17
  runInit,
17
18
  type InitStep,
18
19
  type InitStepEvent,
@@ -64,10 +65,11 @@ export const init = defineCommand({
64
65
  const selectedModel = await pickModel()
65
66
  const provider = KNOWN_PROVIDERS[selectedModel.providerId]
66
67
 
67
- const llmAuth = await collectLLMAuth(provider)
68
+ const existingApiKey = await readExistingProviderApiKey(cwd, selectedModel.providerId)
69
+ const llmAuth = await collectLLMAuth(provider, existingApiKey)
68
70
 
69
71
  const channelChoice = await select({
70
- message: 'Pick a channel to wire (you can add more later by editing typeclaw.json + .env)',
72
+ message: 'Pick a channel to wire (you can add more later by editing typeclaw.json + secrets.json)',
71
73
  options: [
72
74
  { value: 'slack', label: 'Slack' },
73
75
  { value: 'discord', label: 'Discord' },
@@ -435,21 +437,36 @@ function reportHatching(event: Extract<InitStepEvent, { step: 'hatching' }>): vo
435
437
  }
436
438
 
437
439
  // Resolves how the user wants to authenticate to the chosen provider:
438
- // - api-key only (e.g. Fireworks): prompt for the key, write to .env.
440
+ // - api-key only (e.g. Fireworks): prompt for the key, write to secrets.json.
439
441
  // - oauth only (e.g. openai-codex): run the browser flow inline, write
440
442
  // secrets.json. No API key prompt at all.
441
443
  // - both supported (no providers ship this today, but Anthropic will when
442
444
  // wired): ask "API key or OAuth?" first, then dispatch to the chosen path.
443
- async function collectLLMAuth(provider: (typeof KNOWN_PROVIDERS)[KnownProviderId]): Promise<LLMAuth> {
445
+ async function collectLLMAuth(
446
+ provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
447
+ existingApiKey: string | null,
448
+ ): Promise<LLMAuth> {
444
449
  const supportsApiKey = providerSupportsApiKey(provider)
445
450
  const supportsOAuth = providerSupportsOAuth(provider)
446
451
 
452
+ const existingKeyDecision = await decideExistingApiKeyReuse(provider, existingApiKey, (message) =>
453
+ confirm({ message, initialValue: true }),
454
+ )
455
+ if (existingKeyDecision === 'cancel') {
456
+ cancel('Aborted.')
457
+ process.exit(0)
458
+ }
459
+ if (existingKeyDecision === 'reuse' && existingApiKey !== null) {
460
+ log.info(`Using existing ${provider.name} API key from secrets.json.`)
461
+ return { kind: 'api-key', apiKey: existingApiKey }
462
+ }
463
+
447
464
  let method: 'api-key' | 'oauth'
448
465
  if (supportsApiKey && supportsOAuth) {
449
466
  const choice = await select<'api-key' | 'oauth'>({
450
467
  message: `How do you want to authenticate to ${provider.name}?`,
451
468
  options: [
452
- { value: 'api-key', label: 'API key', hint: `saved to .env as ${provider.apiKeyEnv}` },
469
+ { value: 'api-key', label: 'API key', hint: 'saved to secrets.json' },
453
470
  { value: 'oauth', label: 'OAuth (browser login)', hint: 'saved to secrets.json' },
454
471
  ],
455
472
  initialValue: 'api-key',
@@ -467,7 +484,7 @@ async function collectLLMAuth(provider: (typeof KNOWN_PROVIDERS)[KnownProviderId
467
484
 
468
485
  if (method === 'api-key') {
469
486
  const apiKey = await password({
470
- message: `Put your ${provider.name} API key (will be saved to .env as ${provider.apiKeyEnv})`,
487
+ message: `Put your ${provider.name} API key (will be saved to secrets.json)`,
471
488
  validate: (value) => (value && value.length > 0 ? undefined : 'API key is required'),
472
489
  })
473
490
  if (isCancel(apiKey)) {
@@ -480,6 +497,18 @@ async function collectLLMAuth(provider: (typeof KNOWN_PROVIDERS)[KnownProviderId
480
497
  return { kind: 'oauth', runLogin: makeOAuthLoginRunner(buildOAuthCallbacks(provider.name)) }
481
498
  }
482
499
 
500
+ export async function decideExistingApiKeyReuse(
501
+ provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
502
+ existingApiKey: string | null,
503
+ askReuse: (message: string) => Promise<unknown>,
504
+ ): Promise<'reuse' | 'prompt' | 'cancel'> {
505
+ if (!providerSupportsApiKey(provider) || existingApiKey === null) return 'prompt'
506
+
507
+ const reuse = await askReuse(`Reuse existing ${provider.name} API key from secrets.json?`)
508
+ if (isCancel(reuse)) return 'cancel'
509
+ return reuse === true ? 'reuse' : 'prompt'
510
+ }
511
+
483
512
  // Wraps the OAuth lifecycle into the same clack idiom the rest of the wizard
484
513
  // uses: a spinner over the "waiting for login" period, with onAuth printing
485
514
  // the URL the user needs to open and onPrompt falling back to a `text`
package/src/cli/reload.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
- import { resolveHostPort } from '@/container'
3
+ import { resolveHostPort, resolveTuiToken } from '@/container'
4
4
  import { findAgentDir } from '@/init'
5
5
  import { requestReload, type ReloadResult } from '@/reload'
6
6
 
@@ -15,7 +15,7 @@ export const reload = defineCommand({
15
15
  url: {
16
16
  type: 'string',
17
17
  description:
18
- "agent websocket url (defaults to ws://localhost:<host port> discovered from the running container's published port)",
18
+ "agent websocket url (defaults to ws://127.0.0.1:<host port> discovered from the running container's published port)",
19
19
  },
20
20
  timeout: {
21
21
  type: 'string',
@@ -64,5 +64,8 @@ export const reload = defineCommand({
64
64
  async function defaultUrl(): Promise<string> {
65
65
  const cwd = findAgentDir(process.cwd()) ?? process.cwd()
66
66
  const port = await resolveHostPort({ cwd })
67
- return `ws://localhost:${port}`
67
+ const token = await resolveTuiToken({ cwd })
68
+ const url = new URL(`ws://127.0.0.1:${port}`)
69
+ if (token !== null) url.searchParams.set('token', token)
70
+ return url.toString()
68
71
  }
package/src/cli/tui.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
- import { resolveHostPort } from '@/container'
3
+ import { resolveHostPort, resolveTuiToken } from '@/container'
4
4
  import { findAgentDir } from '@/init'
5
5
  import { createTui } from '@/tui'
6
6
 
@@ -18,7 +18,7 @@ export const tui = defineCommand({
18
18
  url: {
19
19
  type: 'string',
20
20
  description:
21
- "agent websocket url (defaults to ws://localhost:<host port> discovered from the running container's published port)",
21
+ "agent websocket url (defaults to ws://127.0.0.1:<host port> discovered from the running container's published port)",
22
22
  },
23
23
  },
24
24
  async run({ args }) {
@@ -31,5 +31,8 @@ export const tui = defineCommand({
31
31
  async function defaultUrl(): Promise<string> {
32
32
  const cwd = findAgentDir(process.cwd()) ?? process.cwd()
33
33
  const port = await resolveHostPort({ cwd })
34
- return `ws://localhost:${port}`
34
+ const token = await resolveTuiToken({ cwd })
35
+ const url = new URL(`ws://127.0.0.1:${port}`)
36
+ if (token !== null) url.searchParams.set('token', token)
37
+ return url.toString()
35
38
  }
@@ -1,4 +1,4 @@
1
- import { accessSync, constants as fsConstants, readFileSync, statSync } from 'node:fs'
1
+ import { accessSync, constants as fsConstants, readFileSync, statSync, writeFileSync } from 'node:fs'
2
2
  import { homedir } from 'node:os'
3
3
  import { isAbsolute, join, resolve } from 'node:path'
4
4
 
@@ -75,7 +75,7 @@ const dockerfileFeatureSchema = z.union([
75
75
  ])
76
76
 
77
77
  // `default(() => ({}))` paired with field-level defaults is the idiom that
78
- // makes both `dockerfile: {}` and an omitted `dockerfile` key resolve to the
78
+ // makes both `docker.file: {}` and an omitted `docker.file` key resolve to the
79
79
  // SAME fully-populated object. A plain `.default({})` would short-circuit the
80
80
  // inner field defaults when the key is omitted, leaving downstream code with
81
81
  // `{ append: undefined, tmux: undefined, ... }` and a `lines.length` crash.
@@ -92,17 +92,61 @@ export const dockerfileSchema = dockerfileObjectSchema.default(() => dockerfileO
92
92
  export type DockerfileConfig = z.infer<typeof dockerfileSchema>
93
93
  export type DockerfileFeatureToggle = z.infer<typeof dockerfileFeatureSchema>
94
94
 
95
+ // The `docker` namespace nests Docker-related blocks under one top-level key
96
+ // so future extensions (e.g. `docker.compose`, `docker.buildArgs`) have a home
97
+ // without polluting the root. Today the only inhabitant is `docker.file`,
98
+ // which holds the same shape that used to live at top-level `dockerfile`.
99
+ // One-time migration (see `migrateLegacyConfigShape`) rewrites the old
100
+ // top-level key into the new path on first load.
101
+ export const dockerSchema = z
102
+ .object({
103
+ file: dockerfileSchema,
104
+ })
105
+ .default(() => ({ file: dockerfileObjectSchema.parse({}) }))
106
+
107
+ export type DockerConfig = z.infer<typeof dockerSchema>
108
+
95
109
  const gitignoreLineSchema = z.string().refine((line) => !/[\r\n]/.test(line), {
96
- message: 'gitignore.append entries must be single gitignore lines; split multiline patterns into array entries',
110
+ message: 'git.ignore.append entries must be single gitignore lines; split multiline patterns into array entries',
111
+ })
112
+
113
+ const gitignoreObjectSchema = z.object({
114
+ append: z.array(gitignoreLineSchema).default([]),
97
115
  })
98
116
 
99
- export const gitignoreSchema = z
117
+ export const gitignoreSchema = gitignoreObjectSchema.default(() => gitignoreObjectSchema.parse({}))
118
+
119
+ export type GitignoreConfig = z.infer<typeof gitignoreSchema>
120
+
121
+ // Same rationale as `dockerSchema`: a `git` namespace today carries `git.ignore`
122
+ // and leaves room for future siblings (e.g. `git.attributes`). The one-time
123
+ // migration also handles the rename of legacy top-level `gitignore`.
124
+ export const gitSchema = z
100
125
  .object({
101
- append: z.array(gitignoreLineSchema).default([]),
126
+ ignore: gitignoreSchema,
102
127
  })
103
- .default({ append: [] })
128
+ .default(() => ({ ignore: gitignoreObjectSchema.parse({}) }))
104
129
 
105
- export type GitignoreConfig = z.infer<typeof gitignoreSchema>
130
+ export type GitConfig = z.infer<typeof gitSchema>
131
+
132
+ const IPV4_CIDR_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/
133
+
134
+ const ipv4CidrSchema = z.string().refine(
135
+ (value) => {
136
+ const match = IPV4_CIDR_PATTERN.exec(value)
137
+ if (!match) return false
138
+ const octets = [match[1], match[2], match[3], match[4]].map(Number)
139
+ if (octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false
140
+ if (match[5] !== undefined) {
141
+ const prefix = Number(match[5])
142
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false
143
+ }
144
+ return true
145
+ },
146
+ {
147
+ message: 'network.allow entries must be IPv4 addresses or CIDR ranges (e.g. "10.0.0.0/16", "10.0.0.2")',
148
+ },
149
+ )
106
150
 
107
151
  // `blockInternal` is the kill-switch for the container-stage egress filter
108
152
  // installed by Dockerfile entrypoint shim: when true, the container is granted
@@ -124,11 +168,38 @@ export type GitignoreConfig = z.infer<typeof gitignoreSchema>
124
168
  // field is discoverable in fresh `typeclaw.json` files. Loopback traffic
125
169
  // (`-o lo`) is always allowed by the shim, so `bun run dev` and local APIs
126
170
  // on `localhost` / `127.0.0.1` are unaffected.
171
+ //
172
+ // `autoAllowResolvers` (default `true`) makes the shim narrowly carve out
173
+ // the container's DNS resolvers — every `nameserver` line in
174
+ // `/etc/resolv.conf` gets a `udp/tcp --dport 53` ACCEPT inserted BEFORE the
175
+ // REJECT rules. This fixes the canonical EC2/GCE/Azure footgun: cloud VPC
176
+ // resolvers live inside RFC1918 (e.g. AWS VPC DNS at `10.0.0.2`), so
177
+ // `blockInternal: true` would otherwise kill every DNS lookup the agent
178
+ // makes. The carve-out is scoped to port 53 only — a compromised agent
179
+ // cannot reach the resolver host on any other port. On a laptop where
180
+ // `/etc/resolv.conf` points at a public resolver (1.1.1.1, 8.8.8.8), the
181
+ // generated ACCEPT rules are no-ops because public IPs are not in the
182
+ // block list to begin with. Opt-out (`false`) is for users who explicitly
183
+ // configure DNS via `.env` (e.g. `DOCKER_DNS=1.1.1.1`) and want a fully
184
+ // closed filter.
185
+ //
186
+ // `allow` is the power-user escape hatch: an explicit list of IPv4 CIDRs
187
+ // or bare IPv4 addresses that punch through the block list wholesale (all
188
+ // ports, all protocols). Use case: VPC-private services the agent must
189
+ // reach by IP — internal APIs, RDS endpoints, VPC interface endpoints for
190
+ // S3/Bedrock. Each entry inserts an unscoped `iptables -A OUTPUT -d <cidr>
191
+ // -j ACCEPT` before the REJECT rules. IPv4 only: the carve-out is for
192
+ // destinations the operator names explicitly, and every cloud VPC we
193
+ // support is IPv4-routable. Validation at parse time rejects non-CIDR
194
+ // strings, IPv6 forms, and out-of-range octets so a typo in
195
+ // `typeclaw.json` surfaces immediately instead of at container boot.
127
196
  export const networkSchema = z
128
197
  .object({
129
198
  blockInternal: z.boolean().default(true),
199
+ autoAllowResolvers: z.boolean().default(true),
200
+ allow: z.array(ipv4CidrSchema).default([]),
130
201
  })
131
- .default({ blockInternal: true })
202
+ .default({ blockInternal: true, autoAllowResolvers: true, allow: [] })
132
203
 
133
204
  export type NetworkConfig = z.infer<typeof networkSchema>
134
205
 
@@ -152,8 +223,8 @@ export const configSchema = z
152
223
  channels: channelsSchema,
153
224
  portForward: portForwardSchema,
154
225
  network: networkSchema,
155
- dockerfile: dockerfileSchema,
156
- gitignore: gitignoreSchema,
226
+ docker: dockerSchema,
227
+ git: gitSchema,
157
228
  })
158
229
  .catchall(z.unknown())
159
230
 
@@ -243,8 +314,8 @@ export const FIELD_EFFECTS: Record<string, FieldEffect> = {
243
314
  channels: 'applied',
244
315
  portForward: 'restart-required',
245
316
  network: 'restart-required',
246
- dockerfile: 'restart-required',
247
- gitignore: 'restart-required',
317
+ 'docker.file': 'restart-required',
318
+ 'git.ignore': 'restart-required',
248
319
  }
249
320
 
250
321
  // Stable JSON for value comparison. Fields are small JSON-shaped objects, so
@@ -307,8 +378,8 @@ export function extractPluginConfigs(raw: unknown): Record<string, unknown> {
307
378
  'channels',
308
379
  'portForward',
309
380
  'network',
310
- 'dockerfile',
311
- 'gitignore',
381
+ 'docker',
382
+ 'git',
312
383
  ])
313
384
  const result: Record<string, unknown> = {}
314
385
  for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
@@ -330,7 +401,8 @@ export function loadPluginConfigsSync(cwd: string): Record<string, unknown> {
330
401
  } catch {
331
402
  return {}
332
403
  }
333
- return extractPluginConfigs(json)
404
+ const migrated = migrateLegacyConfigShape(json)
405
+ return extractPluginConfigs(migrated.json)
334
406
  }
335
407
 
336
408
  export function loadConfigSync(cwd: string): Config {
@@ -349,13 +421,81 @@ export function loadConfigSync(cwd: string): Config {
349
421
  throw new Error(`${CONFIG_FILE} is not valid JSON: ${detail}`)
350
422
  }
351
423
 
352
- const result = configSchema.safeParse(json)
424
+ const migrated = migrateLegacyConfigShape(json)
425
+ if (migrated.changed) {
426
+ persistMigratedConfig(cwd, migrated.json)
427
+ }
428
+
429
+ const result = configSchema.safeParse(migrated.json)
353
430
  if (!result.success) {
354
431
  throw new Error(`${CONFIG_FILE} is invalid: ${formatZodError(result.error)}`)
355
432
  }
356
433
  return result.data
357
434
  }
358
435
 
436
+ // One-shot rename of legacy top-level `dockerfile` / `gitignore` keys into the
437
+ // nested `docker.file` / `git.ignore` shape introduced for namespace
438
+ // extensibility (`docker.compose`, `git.attributes`, etc. land here later
439
+ // without a second migration). Called from every entry point that reads
440
+ // `typeclaw.json` so the rest of the pipeline only ever sees the new shape.
441
+ //
442
+ // Precedence when both legacy and new keys coexist: the new shape wins and
443
+ // the legacy key is dropped silently. Two ways this happens in practice:
444
+ // 1. User hand-edited the new shape after auto-migration but forgot to
445
+ // delete the legacy key.
446
+ // 2. Two `typeclaw start` invocations raced on a stale checkout.
447
+ // Either way, the new shape is the source of truth — losing the legacy
448
+ // duplicate is the right call because it would otherwise be shadowed at
449
+ // parse time anyway (`configSchema` has no `dockerfile`/`gitignore` keys).
450
+ export function migrateLegacyConfigShape(json: unknown): { json: unknown; changed: boolean } {
451
+ if (typeof json !== 'object' || json === null || Array.isArray(json)) {
452
+ return { json, changed: false }
453
+ }
454
+
455
+ const obj = json as Record<string, unknown>
456
+ const hasLegacyDockerfile = 'dockerfile' in obj
457
+ const hasLegacyGitignore = 'gitignore' in obj
458
+ if (!hasLegacyDockerfile && !hasLegacyGitignore) {
459
+ return { json, changed: false }
460
+ }
461
+
462
+ const next: Record<string, unknown> = { ...obj }
463
+ if (hasLegacyDockerfile) {
464
+ const legacy = next.dockerfile
465
+ delete next.dockerfile
466
+ if (!('docker' in next)) {
467
+ next.docker = { file: legacy }
468
+ } else if (isPlainObject(next.docker) && !('file' in next.docker)) {
469
+ next.docker = { ...next.docker, file: legacy }
470
+ }
471
+ }
472
+ if (hasLegacyGitignore) {
473
+ const legacy = next.gitignore
474
+ delete next.gitignore
475
+ if (!('git' in next)) {
476
+ next.git = { ignore: legacy }
477
+ } else if (isPlainObject(next.git) && !('ignore' in next.git)) {
478
+ next.git = { ...next.git, ignore: legacy }
479
+ }
480
+ }
481
+ return { json: next, changed: true }
482
+ }
483
+
484
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
485
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
486
+ }
487
+
488
+ function persistMigratedConfig(cwd: string, json: unknown): void {
489
+ try {
490
+ writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(json, null, 2)}\n`)
491
+ } catch {
492
+ // Best-effort write-back: the migration is also applied in-memory on every
493
+ // load, so a read-only filesystem (e.g. snapshotted CI checkout) just
494
+ // means the rewrite retries next start. Surfacing the error would brick
495
+ // load paths the user didn't ask to mutate.
496
+ }
497
+ }
498
+
359
499
  export type ValidateConfigResult = { ok: true } | { ok: false; reason: string }
360
500
 
361
501
  // Missing file → ok (matches `loadMounts` in src/container/up.ts; `isInitialized`
@@ -384,7 +524,12 @@ export function validateConfig(cwd: string): ValidateConfigResult {
384
524
  return { ok: false, reason: `${CONFIG_FILE} is not valid JSON: ${detail}` }
385
525
  }
386
526
 
387
- const result = configSchema.safeParse(json)
527
+ const migrated = migrateLegacyConfigShape(json)
528
+ if (migrated.changed) {
529
+ persistMigratedConfig(cwd, migrated.json)
530
+ }
531
+
532
+ const result = configSchema.safeParse(migrated.json)
388
533
  if (!result.success) {
389
534
  return { ok: false, reason: `${CONFIG_FILE} is invalid: ${formatZodError(result.error)}` }
390
535
  }
@@ -1,13 +1,17 @@
1
1
  export {
2
2
  config,
3
3
  configSchema,
4
+ dockerSchema,
5
+ dockerfileSchema,
4
6
  expandMountPath,
5
7
  extractPluginConfigs,
6
8
  getConfig,
9
+ gitSchema,
10
+ gitignoreSchema,
7
11
  loadConfigSync,
8
12
  loadPluginConfigsSync,
13
+ migrateLegacyConfigShape,
9
14
  mountSchema,
10
- dockerfileSchema,
11
15
  portForwardSchema,
12
16
  reloadConfig,
13
17
  resolveModel,
@@ -16,7 +20,10 @@ export {
16
20
  type Config,
17
21
  type ConfigChange,
18
22
  type ConfigReloadDiff,
23
+ type DockerConfig,
19
24
  type DockerfileConfig,
25
+ type GitConfig,
26
+ type GitignoreConfig,
20
27
  type Mount,
21
28
  type PortForward,
22
29
  type ValidateConfigResult,
@@ -1,5 +1,5 @@
1
1
  export { logs, planLogs, type LogsPlan, type LogsResult } from './logs'
2
- export { CONTAINER_PORT, findFreePort, resolveHostPort } from './port'
2
+ export { CONTAINER_PORT, TUI_TOKEN_LABEL, findFreePort, resolveHostPort, resolveTuiToken } from './port'
3
3
  export { planShell, shell, type ShellPlan, type ShellResult } from './shell'
4
4
  export { status, type ContainerStatus, type StatusOptions } from './status'
5
5
  export {
@@ -17,6 +17,8 @@ export {
17
17
  } from './shared'
18
18
  export {
19
19
  planStart,
20
+ refreshDockerfile,
21
+ refreshGitignore,
20
22
  start,
21
23
  type HostDaemonStatus,
22
24
  type PlanStartOptions,
@@ -13,6 +13,7 @@ import { containerNameFromCwd, defaultDockerExec, type DockerExec } from './shar
13
13
  // works: containers started before this change used `-p 8973:8973`, and after
14
14
  // the upgrade `docker port <name> 8973/tcp` still resolves correctly.
15
15
  export const CONTAINER_PORT = 8973
16
+ export const TUI_TOKEN_LABEL = 'dev.typeclaw.tui-token'
16
17
 
17
18
  // Asks the kernel for a free TCP port. When `preferred` is supplied, tries
18
19
  // that port first; if it's already bound, falls back to a kernel-assigned
@@ -102,6 +103,15 @@ export async function resolveHostPort(options: ResolveHostPortOptions): Promise<
102
103
  return loadConfigSync(options.cwd).port
103
104
  }
104
105
 
106
+ export async function resolveTuiToken(options: { cwd: string; exec?: DockerExec }): Promise<string | null> {
107
+ const exec = options.exec ?? defaultDockerExec
108
+ const containerName = containerNameFromCwd(options.cwd)
109
+ const result = await exec(['inspect', '--format', `{{ index .Config.Labels "${TUI_TOKEN_LABEL}" }}`, containerName])
110
+ if (result.exitCode !== 0) return null
111
+ const token = result.stdout.trim()
112
+ return token.length > 0 && token !== '<no value>' ? token : null
113
+ }
114
+
105
115
  async function queryDockerHostPort(exec: DockerExec, containerName: string): Promise<number | null> {
106
116
  const result = await exec(['port', containerName, `${CONTAINER_PORT}/tcp`])
107
117
  if (result.exitCode !== 0) return null