typeclaw 0.1.4 → 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.4",
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,42 @@ 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',
97
111
  })
98
112
 
99
- export const gitignoreSchema = z
113
+ const gitignoreObjectSchema = z.object({
114
+ append: z.array(gitignoreLineSchema).default([]),
115
+ })
116
+
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>
106
131
 
107
132
  const IPV4_CIDR_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/
108
133
 
@@ -198,8 +223,8 @@ export const configSchema = z
198
223
  channels: channelsSchema,
199
224
  portForward: portForwardSchema,
200
225
  network: networkSchema,
201
- dockerfile: dockerfileSchema,
202
- gitignore: gitignoreSchema,
226
+ docker: dockerSchema,
227
+ git: gitSchema,
203
228
  })
204
229
  .catchall(z.unknown())
205
230
 
@@ -289,8 +314,8 @@ export const FIELD_EFFECTS: Record<string, FieldEffect> = {
289
314
  channels: 'applied',
290
315
  portForward: 'restart-required',
291
316
  network: 'restart-required',
292
- dockerfile: 'restart-required',
293
- gitignore: 'restart-required',
317
+ 'docker.file': 'restart-required',
318
+ 'git.ignore': 'restart-required',
294
319
  }
295
320
 
296
321
  // Stable JSON for value comparison. Fields are small JSON-shaped objects, so
@@ -353,8 +378,8 @@ export function extractPluginConfigs(raw: unknown): Record<string, unknown> {
353
378
  'channels',
354
379
  'portForward',
355
380
  'network',
356
- 'dockerfile',
357
- 'gitignore',
381
+ 'docker',
382
+ 'git',
358
383
  ])
359
384
  const result: Record<string, unknown> = {}
360
385
  for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
@@ -376,7 +401,8 @@ export function loadPluginConfigsSync(cwd: string): Record<string, unknown> {
376
401
  } catch {
377
402
  return {}
378
403
  }
379
- return extractPluginConfigs(json)
404
+ const migrated = migrateLegacyConfigShape(json)
405
+ return extractPluginConfigs(migrated.json)
380
406
  }
381
407
 
382
408
  export function loadConfigSync(cwd: string): Config {
@@ -395,13 +421,81 @@ export function loadConfigSync(cwd: string): Config {
395
421
  throw new Error(`${CONFIG_FILE} is not valid JSON: ${detail}`)
396
422
  }
397
423
 
398
- 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)
399
430
  if (!result.success) {
400
431
  throw new Error(`${CONFIG_FILE} is invalid: ${formatZodError(result.error)}`)
401
432
  }
402
433
  return result.data
403
434
  }
404
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
+
405
499
  export type ValidateConfigResult = { ok: true } | { ok: false; reason: string }
406
500
 
407
501
  // Missing file → ok (matches `loadMounts` in src/container/up.ts; `isInitialized`
@@ -430,7 +524,12 @@ export function validateConfig(cwd: string): ValidateConfigResult {
430
524
  return { ok: false, reason: `${CONFIG_FILE} is not valid JSON: ${detail}` }
431
525
  }
432
526
 
433
- 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)
434
533
  if (!result.success) {
435
534
  return { ok: false, reason: `${CONFIG_FILE} is invalid: ${formatZodError(result.error)}` }
436
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 {
@@ -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