typeclaw 0.37.2 → 0.37.4

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.
@@ -97,6 +97,11 @@ export const MAX_DEBOUNCE_MS = 4000
97
97
  export const HOT_THRESHOLD_MS = 3000
98
98
  export const MAX_CONSECUTIVE_ABORTS = 3
99
99
  export const CONTEXT_BUFFER_SIZE = 20
100
+ // Observed ("Recent context") messages are awareness-only and replayed in full
101
+ // on every turn (uncached), so one long paste would otherwise re-bloat every
102
+ // subsequent turn until it ages out. Cap each observed message's text; the
103
+ // addressed current message is never capped (it's the actual request).
104
+ export const OBSERVED_MESSAGE_MAX_CHARS = 800
100
105
  // Discord's typing indicator expires after ~10s; an 8s heartbeat keeps it
101
106
  // continuously visible while we debounce + generate without spamming the API.
102
107
  export const TYPING_HEARTBEAT_MS = 8000
@@ -279,6 +284,27 @@ export const WILLINGNESS_NUDGE = [
279
284
  '',
280
285
  '---',
281
286
  ].join('\n')
287
+ // Injected when a `channel_send` ack tripped continuation-willingness, the model
288
+ // did fresh work after it, then ended on an EMPTY `stop` leaf — the answer was
289
+ // computed but never sent (the Kimi/Fireworks empty-completion flake). Distinct
290
+ // from WILLINGNESS_NUDGE: that path is a `channel_reply` that ended the turn and
291
+ // needs `continue: true`; this path is a `channel_send` (which never ends the
292
+ // turn) whose follow-up degenerated, so the model just needs to emit the reply it
293
+ // already worked out. Shares MAX_WILLINGNESS_NUDGES so a turn can't double-nudge.
294
+ export const SEND_WILLINGNESS_NUDGE = [
295
+ '---',
296
+ '**[SYSTEM MESSAGE — not from a human]**',
297
+ '',
298
+ 'You said you would keep working this turn and did the work, but the turn ended',
299
+ 'without sending the result — nothing reached the channel after your last',
300
+ 'message. This is an automated signal from the channel router, not a message',
301
+ 'from anyone in the chat. **Do not acknowledge or reply to this notice itself.**',
302
+ '',
303
+ 'Send the answer you just worked out now via your channel send tool. If you',
304
+ 'genuinely have nothing to report, reply with `NO_REPLY`.',
305
+ '',
306
+ '---',
307
+ ].join('\n')
282
308
  // Rolling window for outbound send-rate telemetry. 5s matches Discord's
283
309
  // rate-limit shape (5 msg / 5 s / channel) and comfortably covers Slack's
284
310
  // 1 msg/s sustained. The window is observational; exceeding the burst
@@ -3387,6 +3413,43 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3387
3413
  // landed — suppress it, as before.
3388
3414
  if (live.successfulChannelSends > successfulSendsBeforePrompt) {
3389
3415
  maybeNudgeContinuationWillingness(live)
3416
+
3417
+ // A `channel_send` ack that promised to keep working, fresh post-ack work,
3418
+ // then an EMPTY `stop` leaf: the model computed the answer in its reasoning
3419
+ // / tool results but never sent it (the Kimi/Fireworks empty-completion
3420
+ // flake). `maybeNudgeContinuationWillingness` above can't catch this — it
3421
+ // reads `lastTerminalReplyAbort`, which only a `channel_reply` sets;
3422
+ // `channel_send` keeps the turn alive and stamps nothing. And the
3423
+ // stranded-toolUse retry below requires `source !== 'leaf'`, but an empty
3424
+ // `stop` leaf recovers as `source: 'leaf'`, so this shape would otherwise
3425
+ // fall straight through to the `endsWithNoReplySignal('')` → `no_reply`
3426
+ // classification. Discriminator (all on existing state, zero false positives
3427
+ // measured across the session corpus): a send landed AND the just-sent text
3428
+ // trips the precision-tuned willingness detector AND the turn-end leaf is a
3429
+ // FRESH empty `stop` (different entry than the ack's leaf — so the model did
3430
+ // post-ack work, not an ack-then-await-user stop). Bounded by
3431
+ // MAX_WILLINGNESS_NUDGES (shared with the reply path); on exhaustion post the
3432
+ // fallback rather than going silent, mirroring the stranded-toolUse path.
3433
+ // Gated on an empty `promptQueue` (like maybeNudgeContinuationWillingness): a
3434
+ // real inbound that coalesced into the just-finished prompt will be answered
3435
+ // by the next drain pass, and drain() splices pending reminders into that
3436
+ // batch — so injecting a stale recovery nudge would prepend it to a live user
3437
+ // message. Skip the nudge AND the fallback in that case and let the trailing
3438
+ // recovery below run; the queued inbound supersedes this turn's silence.
3439
+ if (live.promptQueue.length === 0 && live.currentTurnAuthorId !== null && isEmptyStopAfterWillingnessAck(live)) {
3440
+ if (live.willingnessNudges < MAX_WILLINGNESS_NUDGES) {
3441
+ live.willingnessNudges++
3442
+ logger.warn(
3443
+ `[channels] ${live.keyId} send_willingness_nudge attempt=${live.willingnessNudges}/${MAX_WILLINGNESS_NUDGES} ` +
3444
+ `cause=empty_stop_after_send_ack`,
3445
+ )
3446
+ live.pendingSystemReminders.push(SEND_WILLINGNESS_NUDGE)
3447
+ } else {
3448
+ await postEmptyTurnFallback('empty_stop_after_send_ack_nudges_exhausted')
3449
+ }
3450
+ return
3451
+ }
3452
+
3390
3453
  const trailing = recoverableAssistantText(live.session)
3391
3454
  if (trailing === null || trailing.source !== 'leaf') {
3392
3455
  // A `continue: true` status reply landed, then the turn stranded on an
@@ -4404,7 +4467,7 @@ function composeTurnPrompt(
4404
4467
  if (observed.length > 0) {
4405
4468
  parts.push('## Recent context (not addressed to you, for awareness only)')
4406
4469
  for (const o of observed) {
4407
- parts.push(formatInboundPromptLines(o, adapter))
4470
+ parts.push(formatInboundPromptLines(o, adapter, OBSERVED_MESSAGE_MAX_CHARS))
4408
4471
  }
4409
4472
  parts.push('')
4410
4473
  }
@@ -4463,10 +4526,22 @@ function formatAuthorLine(
4463
4526
  authorName: string,
4464
4527
  authorIsBot: boolean,
4465
4528
  text: string,
4529
+ maxChars?: number,
4466
4530
  ): string {
4467
4531
  const tag = authorIsBot ? ' [bot]' : ''
4468
4532
  const stamp = ts > 0 ? `[${new Date(ts).toISOString()}] ` : ''
4469
- return `${stamp}${formatAuthorReference(adapter, authorId, authorName)} (${authorName})${tag}: ${text}`
4533
+ return `${stamp}${formatAuthorReference(adapter, authorId, authorName)} (${authorName})${tag}: ${capObservedText(text, maxChars)}`
4534
+ }
4535
+
4536
+ // Cap by whole code points so truncation never splits a surrogate pair (emoji,
4537
+ // astral-plane chars) into a dangling half. `text.length` (UTF-16 code units) is
4538
+ // a cheap upper bound on the code-point count, so a string already within the
4539
+ // cap skips the array build.
4540
+ function capObservedText(text: string, maxChars: number | undefined): string {
4541
+ if (maxChars === undefined || text.length <= maxChars) return text
4542
+ const points = Array.from(text)
4543
+ if (points.length <= maxChars) return text
4544
+ return `${points.slice(0, maxChars).join('')} […truncated]`
4470
4545
  }
4471
4546
 
4472
4547
  function formatInboundPromptLines(
@@ -4479,10 +4554,19 @@ function formatInboundPromptLines(
4479
4554
  referenceContext?: InboundReferenceContext
4480
4555
  },
4481
4556
  adapter: AdapterId,
4557
+ maxTextChars?: number,
4482
4558
  ): string {
4483
4559
  const lines = inbound.referenceContext?.sources.map(renderQuoteAnchor) ?? []
4484
4560
  lines.push(
4485
- formatAuthorLine(inbound.ts, adapter, inbound.authorId, inbound.authorName, inbound.authorIsBot, inbound.text),
4561
+ formatAuthorLine(
4562
+ inbound.ts,
4563
+ adapter,
4564
+ inbound.authorId,
4565
+ inbound.authorName,
4566
+ inbound.authorIsBot,
4567
+ inbound.text,
4568
+ maxTextChars,
4569
+ ),
4486
4570
  )
4487
4571
  return lines.join('\n')
4488
4572
  }
@@ -5021,6 +5105,24 @@ function leafIsStrandedToolUse(session: AgentSession): boolean {
5021
5105
  return false
5022
5106
  }
5023
5107
 
5108
+ // True when the turn-end leaf is a FRESH empty `stop` (no text, no tool call,
5109
+ // distinct from the leaf in place at the last successful send) AND the most
5110
+ // recent send to this target was a continuation-willingness ack. This is the
5111
+ // `channel_send` analogue of the `channel_reply` willingness path: the model
5112
+ // acked "I'll check…", did post-ack work, then the follow-up came back as a
5113
+ // clean empty completion that would otherwise be read as a deliberate `NO_REPLY`.
5114
+ // The fresh-leaf check (`!== lastSendLeafId`) is what separates this degeneration
5115
+ // from a legitimate ack-then-stop where the model meant to wait for the user.
5116
+ function isEmptyStopAfterWillingnessAck(live: LiveSession): boolean {
5117
+ const leaf = live.session.sessionManager.getLeafEntry()
5118
+ if (!leaf || leaf.type !== 'message' || leaf.message.role !== 'assistant') return false
5119
+ if (leaf.message.stopReason !== 'stop') return false
5120
+ if (hasToolCall(leaf.message) || visibleAssistantText(leaf.message).trim() !== '') return false
5121
+ if (leaf.id === live.lastSendLeafId) return false
5122
+ const ackText = live.lastSentText.get(consecutiveSendKey(live.key.chat, live.key.thread))
5123
+ return ackText !== undefined && detectContinuationWillingness(ackText)
5124
+ }
5125
+
5024
5126
  function visibleAssistantText(message: AssistantMessage): string {
5025
5127
  return message.content
5026
5128
  .filter((block) => block.type === 'text')
package/src/cli/init.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  hasExistingOAuthCredentials,
40
40
  isDirectoryNonEmpty,
41
41
  isHatched,
42
+ isInitialized,
42
43
  readExistingProviderApiKey,
43
44
  runInit,
44
45
  type GithubInitCredentials,
@@ -139,18 +140,22 @@ export const init = defineCommand({
139
140
  if (existingAgent !== null && existingAgent !== cwd) {
140
141
  console.error(
141
142
  errorLine(
142
- `Refusing to init: a TypeClaw agent already exists at ${existingAgent}. Nested agents are not supported.`,
143
+ `Refusing to init: a TypeClaw agent already exists at ${existingAgent}. Nested agents are not supported. Run init from a directory that is not inside an existing agent.`,
143
144
  ),
144
145
  )
145
146
  process.exit(1)
146
147
  }
147
148
 
148
149
  if (await isHatched(cwd)) {
149
- console.error(errorLine(`TypeClaw has already hatched in ${cwd}.`))
150
+ console.error(
151
+ errorLine(
152
+ `TypeClaw has already hatched in ${cwd}. Use \`typeclaw tui\` to attach or \`typeclaw start\` to run it; init in a different directory to create another agent.`,
153
+ ),
154
+ )
150
155
  process.exit(1)
151
156
  }
152
157
 
153
- if (isDirectoryNonEmpty(cwd)) {
158
+ if (shouldConfirmNonEmptyDirectory(cwd)) {
154
159
  const proceed = await confirm({
155
160
  message: `You're at ${cwd}. The directory is not empty. Do you want to proceed?`,
156
161
  initialValue: false,
@@ -192,7 +197,7 @@ export const init = defineCommand({
192
197
  [
193
198
  'OAuth credentials were saved to `secrets.json` before you aborted.',
194
199
  'Re-run `typeclaw init` here to pick up where you left off (the credentials',
195
- 'will be reused), or delete `secrets.json` if you want a clean restart.',
200
+ 'will be reused), or run `typeclaw init --reset` to start fresh.',
196
201
  ].join('\n'),
197
202
  'Saved OAuth credentials',
198
203
  )
@@ -288,6 +293,14 @@ export const init = defineCommand({
288
293
  })
289
294
  } catch (error) {
290
295
  console.error(errorLine(error instanceof Error ? error.message : String(error)))
296
+ note(
297
+ [
298
+ 'Your answers are saved.',
299
+ 'Re-run `typeclaw init` here to resume, or `typeclaw init --reset` to start fresh.',
300
+ 'Run `typeclaw doctor` to diagnose host/Docker issues.',
301
+ ].join('\n'),
302
+ 'init failed',
303
+ )
291
304
  process.exit(1)
292
305
  }
293
306
 
@@ -296,6 +309,19 @@ export const init = defineCommand({
296
309
  process.exit(1)
297
310
  }
298
311
 
312
+ if (!hatchingOk) {
313
+ note(
314
+ [
315
+ 'The container was built but the agent did not come up.',
316
+ 'Check logs: `typeclaw logs`',
317
+ 'Diagnose: `typeclaw doctor`',
318
+ 'Retry once fixed: `typeclaw start` (your setup is saved).',
319
+ ].join('\n'),
320
+ 'Hatching failed',
321
+ )
322
+ process.exit(1)
323
+ }
324
+
299
325
  if (githubCredentials?.tunnelProvider === 'none') {
300
326
  log.warn(
301
327
  'Webhook delivery is disabled until you add a `tunnels[]` entry or set `channels.github.webhookUrl` manually.',
@@ -335,6 +361,10 @@ export const init = defineCommand({
335
361
  },
336
362
  })
337
363
 
364
+ export function shouldConfirmNonEmptyDirectory(cwd: string): boolean {
365
+ return isDirectoryNonEmpty(cwd) && !isInitialized(cwd)
366
+ }
367
+
338
368
  interface WizardState {
339
369
  catalog?: { options: ModelOption[]; source: 'models.dev' | 'curated'; warning?: string }
340
370
  vendorId?: KnownProviderVendorId
@@ -1750,20 +1780,24 @@ function reportProgress(
1750
1780
  s.stop(event.result.ok ? 'Logged in.' : `OAuth login failed: ${event.result.reason}`)
1751
1781
  break
1752
1782
  case 'install':
1753
- s.stop(event.result.ok ? 'Dependencies installed.' : `Skipped bun install: ${event.result.reason}`)
1783
+ if (event.result.ok) {
1784
+ s.stop('Dependencies installed.')
1785
+ } else {
1786
+ s.error(`Dependency install failed: ${event.result.reason}`)
1787
+ }
1754
1788
  break
1755
1789
  case 'dockerfile':
1756
1790
  if (event.result.ok) {
1757
1791
  s.stop(event.result.devMode ? 'Dockerfile written (dev mode).' : 'Dockerfile written.')
1758
1792
  } else {
1759
- s.stop(`Skipped Dockerfile: ${event.result.reason}`)
1793
+ s.error(`Dockerfile generation failed: ${event.result.reason}`)
1760
1794
  }
1761
1795
  break
1762
1796
  case 'git':
1763
1797
  if (event.result.ok) {
1764
1798
  s.stop(event.result.skipped ? 'Git repository already exists.' : 'Git repository initialized.')
1765
1799
  } else {
1766
- s.stop(`Skipped git init: ${event.result.reason}`)
1800
+ s.error(`git init failed — continuing without a repo: ${event.result.reason}`)
1767
1801
  }
1768
1802
  break
1769
1803
  }
package/src/cli/qr.ts CHANGED
@@ -6,6 +6,8 @@ import { promisify } from 'node:util'
6
6
 
7
7
  import QRCode from 'qrcode'
8
8
 
9
+ import { isMacOS, isWindows } from '@/shared'
10
+
9
11
  const execFileAsync = promisify(execFile)
10
12
 
11
13
  // The upstream LINE SDK's QR login hands back a raw auth URL
@@ -108,12 +110,11 @@ async function writeQRHtmlFile(
108
110
  }
109
111
 
110
112
  async function openInBrowser(filePath: string): Promise<void> {
111
- const platform = process.platform
112
- if (platform === 'darwin') {
113
+ if (isMacOS()) {
113
114
  await execFileAsync('open', [filePath])
114
115
  return
115
116
  }
116
- if (platform === 'win32') {
117
+ if (isWindows()) {
117
118
  await execFileAsync('cmd', ['/c', 'start', '', filePath])
118
119
  return
119
120
  }
package/src/cli/ui.ts CHANGED
@@ -14,14 +14,18 @@ type ClackInput = Pick<NodeJS.ReadStream, 'isTTY' | 'setRawMode' | 'resume'>
14
14
  // Bun's readline keypress wiring only transitions stdin into flowing raw mode
15
15
  // reliably once the stream has already been resumed; on a never-resumed stdin
16
16
  // the picker renders but arrow keys echo as raw `^[[B` and never advance it.
17
- // Local terminals dodge this because stdin was already flowing. So before every
18
- // picker: clear any stale raw mode for a clean baseline, then resume the stream.
19
- // Never pause() here a previously-paused process.stdin does not reliably
20
- // re-flow under Bun, which is the same failure this resume() is fixing.
17
+ // Local terminals dodge this because stdin was already flowing. Worse, after a
18
+ // pi-tui viewer (ProcessTerminal.stop() calls process.stdin.pause()), a plain
19
+ // resume() does NOT re-flow stdin under Bun, so the next picker is dead over
20
+ // SSH. Toggling raw mode on->off forces the TTY read back into flowing mode;
21
+ // the trailing resume() + non-raw state is the baseline clack expects.
22
+ // Never pause() here — a paused process.stdin does not reliably re-flow.
21
23
  export function prepareStdinForClack(input: ClackInput = process.stdin): void {
22
24
  if (!input.isTTY) return
25
+ input.resume()
23
26
  if (typeof input.setRawMode === 'function') {
24
27
  try {
28
+ input.setRawMode(true)
25
29
  input.setRawMode(false)
26
30
  } catch {
27
31
  /* terminal already torn down */
@@ -15,11 +15,12 @@ import {
15
15
  resolveHostPort,
16
16
  type DockerExec,
17
17
  } from '@/container'
18
- import { isDaemonReachable, send } from '@/hostd'
18
+ import { homeRoot, isDaemonReachable, send } from '@/hostd'
19
19
  import { resolveBaseImageVersion } from '@/init/cli-version'
20
20
  import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
21
21
  import { detectMissingDeps } from '@/init/ensure-deps'
22
22
  import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
23
+ import { detectWsl, isWindows, isWindowsDriveMount, type WslInfo } from '@/shared'
23
24
 
24
25
  import { buildChannelChecks } from './channel-checks'
25
26
  import type { DoctorCheck } from './types'
@@ -37,8 +38,11 @@ export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): Docto
37
38
  agentFolderGitRepo(),
38
39
  configValid(),
39
40
  hostdHomeWritable(),
41
+ wslDriveMount(),
42
+ windowsSecretPerms(),
40
43
  hostdReachable(),
41
44
  hostdRegistration(),
45
+ windowsBindMount(),
42
46
  containerState(dockerExec),
43
47
  containerHostPort(),
44
48
  ...buildChannelChecks(),
@@ -237,6 +241,142 @@ function hostdHomeWritable(): DoctorCheck {
237
241
  }
238
242
  }
239
243
 
244
+ export type WslDriveMountDeps = {
245
+ detect: () => WslInfo
246
+ isWindowsDriveMount: (path: string) => boolean
247
+ typeclawHome: () => string
248
+ }
249
+
250
+ // Under WSL, files on a Windows-drive mount (/mnt/c/...) don't enforce Unix
251
+ // permissions, so the 0600 chmod that protects secrets.json and the encryption
252
+ // keys is silently ignored — they become readable by every local user. Warn
253
+ // when either the agent folder or ~/.typeclaw lives on such a mount.
254
+ export function wslDriveMount(deps: Partial<WslDriveMountDeps> = {}): DoctorCheck {
255
+ const detect = deps.detect ?? detectWsl
256
+ const onWindowsDrive = deps.isWindowsDriveMount ?? isWindowsDriveMount
257
+ const typeclawHome = deps.typeclawHome ?? homeRoot
258
+
259
+ return {
260
+ name: 'hostd.wsl-drive-mount',
261
+ category: 'hostd',
262
+ description: 'agent state is not on a Windows-drive mount under WSL',
263
+ async run(ctx) {
264
+ if (!detect().isWsl) return { status: 'ok', message: 'not running under WSL' }
265
+
266
+ const offenders: string[] = []
267
+ if (ctx.hasAgentFolder && onWindowsDrive(ctx.cwd)) offenders.push(`agent folder: ${ctx.cwd}`)
268
+ const home = typeclawHome()
269
+ if (onWindowsDrive(home)) offenders.push(`hostd home: ${home}`)
270
+
271
+ if (offenders.length === 0) {
272
+ return { status: 'ok', message: 'agent state is on the Linux filesystem' }
273
+ }
274
+
275
+ return {
276
+ status: 'warning',
277
+ message: 'agent state is on a Windows-drive mount; file permissions are not enforced',
278
+ details: [
279
+ ...offenders,
280
+ 'chmod is a no-op on /mnt/<drive> (DrvFs/9p), so secrets.json and encryption keys become world-readable.',
281
+ ],
282
+ fix: {
283
+ description:
284
+ 'Move the agent folder to the WSL Linux filesystem (e.g. ~/my-agent) and, if needed, set TYPECLAW_HOME to a Linux path.',
285
+ },
286
+ }
287
+ },
288
+ }
289
+ }
290
+
291
+ export type WindowsSecretPermsDeps = {
292
+ isWindows: () => boolean
293
+ typeclawHome: () => string
294
+ }
295
+
296
+ // On native Windows the 0600/0700 modes typeclaw sets on secrets.json and the
297
+ // encryption keys are no-ops — NTFS uses ACLs, not Unix modes — so their
298
+ // confidentiality rests on the inherited %USERPROFILE% ACLs rather than the
299
+ // hardening typeclaw enforces on POSIX. Surface that as a warning.
300
+ export function windowsSecretPerms(deps: Partial<WindowsSecretPermsDeps> = {}): DoctorCheck {
301
+ const onWindows = deps.isWindows ?? isWindows
302
+ const typeclawHome = deps.typeclawHome ?? homeRoot
303
+
304
+ return {
305
+ name: 'hostd.windows-secret-perms',
306
+ category: 'hostd',
307
+ description: 'secrets rely on enforced file permissions (native Windows)',
308
+ async run(ctx) {
309
+ if (!onWindows()) return { status: 'ok', message: 'not running on native Windows' }
310
+
311
+ const details = [`hostd home: ${typeclawHome()}`]
312
+ if (ctx.hasAgentFolder) details.push(`agent folder: ${ctx.cwd}`)
313
+ details.push(
314
+ 'NTFS ignores the 0600/0700 chmod typeclaw applies to secrets.json and encryption keys; their confidentiality relies on the inherited %USERPROFILE% ACLs instead.',
315
+ )
316
+
317
+ return {
318
+ status: 'warning',
319
+ message: 'native Windows does not enforce the file modes that protect agent secrets',
320
+ details,
321
+ fix: {
322
+ description:
323
+ 'Keep the agent folder and ~/.typeclaw under your user profile, where default ACLs restrict access to your account; avoid a shared or everyone-readable location.',
324
+ },
325
+ }
326
+ },
327
+ }
328
+ }
329
+
330
+ export type WindowsBindMountDeps = {
331
+ isWindows: () => boolean
332
+ }
333
+
334
+ // Docker Desktop bind-mounts the agent folder into its Linux VM, and a few host
335
+ // locations don't survive that translation: UNC/network paths (\\server\share)
336
+ // aren't shareable, OneDrive-virtualized folders fail on placeholder files, and
337
+ // paths near the legacy MAX_PATH (260) limit break mid-build. Flag them so
338
+ // `typeclaw start` fails loudly here instead of cryptically at mount time.
339
+ export function windowsBindMount(deps: Partial<WindowsBindMountDeps> = {}): DoctorCheck {
340
+ const onWindows = deps.isWindows ?? isWindows
341
+
342
+ return {
343
+ name: 'container.windows-bind-mount',
344
+ category: 'container',
345
+ description: 'agent folder is bind-mountable by Docker Desktop (native Windows)',
346
+ applies: (ctx) => ctx.hasAgentFolder,
347
+ async run(ctx) {
348
+ if (!onWindows()) return { status: 'ok', message: 'not running on native Windows' }
349
+
350
+ const issues = detectWindowsBindMountIssues(ctx.cwd)
351
+ if (issues.length === 0) return { status: 'ok', message: 'agent folder path is bind-mountable' }
352
+
353
+ return {
354
+ status: 'warning',
355
+ message: 'agent folder may not bind-mount cleanly under Docker Desktop',
356
+ details: issues,
357
+ fix: {
358
+ description:
359
+ 'Use a local, short, non-OneDrive path under your user profile (e.g. C:\\agents\\my-agent), then re-run typeclaw start.',
360
+ },
361
+ }
362
+ },
363
+ }
364
+ }
365
+
366
+ export function detectWindowsBindMountIssues(path: string): string[] {
367
+ const issues: string[] = []
368
+ if (path.startsWith('\\\\')) {
369
+ issues.push(`UNC/network path is not shareable with Docker Desktop: ${path}`)
370
+ }
371
+ if (path.split(/[\\/]/).some((seg) => /^onedrive(?: -.*)?$/i.test(seg))) {
372
+ issues.push(`path is under OneDrive, where virtualized files can break bind mounts: ${path}`)
373
+ }
374
+ if (path.length > 260) {
375
+ issues.push(`path length ${path.length} exceeds the legacy Windows MAX_PATH (260) limit`)
376
+ }
377
+ return issues
378
+ }
379
+
240
380
  function hostdReachable(): DoctorCheck {
241
381
  return {
242
382
  name: 'hostd.reachable',
@@ -362,9 +502,12 @@ function loadConfigStrictForTemplate(
362
502
  return { dockerfile: cfg.docker.file, gitignore: cfg.git.ignore }
363
503
  }
364
504
 
505
+ // Normalizes CRLF to LF: the managed templates are emitted with `\n`, but a
506
+ // checkout under Git for Windows (core.autocrlf=true) rewrites them to `\r\n`,
507
+ // which would make the byte-exact template comparison report a false divergence.
365
508
  async function safeRead(path: string): Promise<string | null> {
366
509
  try {
367
- return await readFile(path, 'utf8')
510
+ return (await readFile(path, 'utf8')).replace(/\r\n/g, '\n')
368
511
  } catch {
369
512
  return null
370
513
  }
@@ -1,4 +1,5 @@
1
1
  import { getBun } from '@/container/shared'
2
+ import { isMacOS, isWindows } from '@/shared'
2
3
 
3
4
  export type TailscaleExecResult = { exitCode: number; stdout: string; stderr: string }
4
5
  export type TailscaleExec = (args: string[]) => Promise<TailscaleExecResult>
@@ -33,6 +34,7 @@ type TailscaleStatus = {
33
34
  }
34
35
 
35
36
  const MACOS_APP_CLI = '/Applications/Tailscale.app/Contents/MacOS/Tailscale'
37
+ const WINDOWS_CLI = 'C:\\Program Files\\Tailscale\\tailscale.exe'
36
38
 
37
39
  export function createTailscaleServeManager(opts: TailscaleServeManagerOptions): TailscaleServeManager {
38
40
  const exec = opts.exec ?? defaultTailscaleExec
@@ -129,8 +131,17 @@ async function checkRunning(exec: TailscaleExec): Promise<{ ok: true } | { ok: f
129
131
  return { ok: true }
130
132
  }
131
133
 
134
+ // `tailscale` on PATH is tried first everywhere; the platform-specific absolute
135
+ // path is a fallback for GUI installs that leave the CLI off PATH (the macOS app
136
+ // bundle, the Windows default install dir).
137
+ export function tailscaleCandidates(platform: NodeJS.Platform = process.platform): string[] {
138
+ if (isMacOS(platform)) return ['tailscale', MACOS_APP_CLI]
139
+ if (isWindows(platform)) return ['tailscale', WINDOWS_CLI]
140
+ return ['tailscale']
141
+ }
142
+
132
143
  export const defaultTailscaleExec: TailscaleExec = async (args) => {
133
- const candidates = process.platform === 'darwin' ? ['tailscale', MACOS_APP_CLI] : ['tailscale']
144
+ const candidates = tailscaleCandidates()
134
145
  let lastError = 'tailscale command not found'
135
146
 
136
147
  for (const candidate of candidates) {
package/src/init/index.ts CHANGED
@@ -20,7 +20,14 @@ import {
20
20
  type KnownProviderId,
21
21
  type ModelRef,
22
22
  } from '@/config/providers'
23
- import { checkDockerAvailable, type DockerAvailability, type DockerExec, start } from '@/container'
23
+ import {
24
+ checkDockerAvailable,
25
+ type DockerAvailability,
26
+ type DockerExec,
27
+ start,
28
+ type StartResult,
29
+ stop,
30
+ } from '@/container'
24
31
  import { commitSystemFile } from '@/git/system-commit'
25
32
  import { createSecretsStoreForAgent, type Channels, type Secret, SecretsBackend } from '@/secrets'
26
33
  import { hostLocaleIsCjk } from '@/shared/host-locale'
@@ -376,10 +383,12 @@ export async function runInit({
376
383
  emit({ step: 'install', phase: 'start' })
377
384
  const install = await installRunner(cwd)
378
385
  emit({ step: 'install', phase: 'done', result: install })
386
+ if (!install.ok) throw new Error(`Dependency install failed: ${install.reason}`)
379
387
 
380
388
  emit({ step: 'dockerfile', phase: 'start' })
381
389
  const docker = await writeDockerAssets(cwd)
382
390
  emit({ step: 'dockerfile', phase: 'done', result: docker })
391
+ if (!docker.ok) throw new Error(`Dockerfile generation failed: ${docker.reason}`)
383
392
 
384
393
  emit({ step: 'git', phase: 'start' })
385
394
  const git = await initGitRepo(cwd)
@@ -417,6 +426,7 @@ export async function defaultRunHatching({
417
426
  tui: tuiFactory = createTui,
418
427
  waitForAgent: waitForAgentFn = waitForAgent,
419
428
  runClaim = defaultRunClaim,
429
+ stopContainer = stop,
420
430
  }: {
421
431
  cwd: string
422
432
  port: number
@@ -426,14 +436,17 @@ export async function defaultRunHatching({
426
436
  tui?: typeof createTui
427
437
  waitForAgent?: typeof waitForAgent
428
438
  runClaim?: ClaimRunner
439
+ stopContainer?: typeof stop
429
440
  }): Promise<HatchingResult> {
441
+ let launch: Extract<StartResult, { ok: true }> | null = null
430
442
  try {
431
- const launch = await startContainer({
443
+ const startResult = await startContainer({
432
444
  cwd,
433
445
  preferredHostPort: port,
434
446
  ...(cliEntry !== undefined ? { cliEntry } : {}),
435
447
  })
436
- if (!launch.ok) return { ok: false, reason: launch.reason }
448
+ if (!startResult.ok) return { ok: false, reason: startResult.reason }
449
+ launch = startResult
437
450
 
438
451
  // start() may have allocated a different host port (the preferred one was
439
452
  // bound). Use the actually-published port for the TUI handshake instead of
@@ -455,6 +468,9 @@ export async function defaultRunHatching({
455
468
  await tui.run()
456
469
  return { ok: true }
457
470
  } catch (error) {
471
+ if (launch !== null && !launch.alreadyRunning) {
472
+ await stopContainer({ cwd }).catch(() => {})
473
+ }
458
474
  return { ok: false, reason: error instanceof Error ? error.message : String(error) }
459
475
  }
460
476
  }
@@ -709,7 +725,7 @@ export async function initGitRepo(cwd: string): Promise<GitInitResult> {
709
725
  const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
710
726
  if (!bun) return { ok: false, reason: 'bun runtime not available' }
711
727
 
712
- if (existsSync(join(cwd, '.git'))) return { ok: true, skipped: true }
728
+ const hasGit = existsSync(join(cwd, '.git'))
713
729
 
714
730
  // Author the initial commit as TypeClaw itself. The agent is still unnamed
715
731
  // (IDENTITY.md is empty and hatching hasn't run), so the agent identity will
@@ -724,10 +740,21 @@ export async function initGitRepo(cwd: string): Promise<GitInitResult> {
724
740
  }
725
741
 
726
742
  try {
727
- const init = bun.spawn({ cmd: ['git', 'init', '-b', 'main'], cwd, env, stdout: 'pipe', stderr: 'pipe' })
728
- if ((await init.exited) !== 0) {
729
- const stderr = await new Response(init.stderr).text()
730
- return { ok: false, reason: `git init failed: ${stderr.trim() || 'no stderr'}` }
743
+ if (hasGit) {
744
+ const head = bun.spawn({
745
+ cmd: ['git', 'rev-parse', '--verify', 'HEAD'],
746
+ cwd,
747
+ env,
748
+ stdout: 'pipe',
749
+ stderr: 'pipe',
750
+ })
751
+ if ((await head.exited) === 0) return { ok: true, skipped: true }
752
+ } else {
753
+ const init = bun.spawn({ cmd: ['git', 'init', '-b', 'main'], cwd, env, stdout: 'pipe', stderr: 'pipe' })
754
+ if ((await init.exited) !== 0) {
755
+ const stderr = await new Response(init.stderr).text()
756
+ return { ok: false, reason: `git init failed: ${stderr.trim() || 'no stderr'}` }
757
+ }
731
758
  }
732
759
 
733
760
  const add = bun.spawn({ cmd: ['git', 'add', '.'], cwd, env, stdout: 'pipe', stderr: 'pipe' })