typeclaw 0.37.3 → 0.37.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.
Files changed (58) hide show
  1. package/README.md +69 -46
  2. package/package.json +1 -1
  3. package/src/agent/compaction.ts +24 -15
  4. package/src/agent/doctor.ts +6 -1
  5. package/src/agent/session-origin.ts +101 -173
  6. package/src/agent/subagents.ts +146 -14
  7. package/src/agent/system-prompt.ts +46 -48
  8. package/src/agent/todo/scope.ts +4 -2
  9. package/src/agent/tools/channel-reply.ts +7 -9
  10. package/src/bundled-plugins/memory/index.ts +33 -33
  11. package/src/bundled-plugins/memory/load-memory.ts +92 -35
  12. package/src/bundled-plugins/memory/slug.ts +19 -0
  13. package/src/bundled-plugins/memory/turn-dedup.ts +32 -29
  14. package/src/bundled-plugins/security/policies/private-surface-read.ts +4 -1
  15. package/src/bundled-plugins/tool-result-cap/README.md +7 -7
  16. package/src/bundled-plugins/tool-result-cap/index.ts +1 -1
  17. package/src/channels/adapters/discord-bot.ts +11 -4
  18. package/src/channels/adapters/github/inbound.ts +68 -43
  19. package/src/channels/adapters/github/index.ts +57 -9
  20. package/src/channels/adapters/github/recover-failed-deliveries.ts +270 -0
  21. package/src/channels/adapters/kakaotalk.ts +5 -1
  22. package/src/channels/adapters/mention-hints.ts +75 -0
  23. package/src/channels/adapters/slack-bot.ts +8 -2
  24. package/src/channels/continuation-willingness.ts +216 -68
  25. package/src/channels/router.ts +149 -15
  26. package/src/cli/dreams.ts +2 -2
  27. package/src/cli/init.ts +41 -7
  28. package/src/cli/inspect.ts +2 -2
  29. package/src/cli/logs.ts +2 -2
  30. package/src/cli/qr.ts +4 -3
  31. package/src/cli/require-agent-dir.ts +31 -0
  32. package/src/cli/shell.ts +2 -2
  33. package/src/cli/stop.ts +2 -2
  34. package/src/cli/tui.ts +20 -6
  35. package/src/cli/ui.ts +8 -4
  36. package/src/container/shared.ts +18 -0
  37. package/src/container/start.ts +1 -1
  38. package/src/doctor/checks.ts +145 -2
  39. package/src/hostd/client.ts +48 -52
  40. package/src/hostd/daemon.ts +82 -39
  41. package/src/hostd/paths.ts +22 -2
  42. package/src/hostd/spawn.ts +7 -0
  43. package/src/hostd/tailscale.ts +12 -1
  44. package/src/init/index.ts +35 -8
  45. package/src/init/kakaotalk-auth.ts +2 -2
  46. package/src/init/packagejson.ts +2 -2
  47. package/src/init/run-bun-install.ts +71 -37
  48. package/src/inspect/transcript-view.ts +15 -2
  49. package/src/plugin/loader.ts +7 -4
  50. package/src/portbroker/hostd-client.ts +32 -6
  51. package/src/sandbox/session-tmp.ts +6 -1
  52. package/src/secrets/export-claude-credentials-file.ts +2 -2
  53. package/src/shared/index.ts +4 -0
  54. package/src/shared/platform.ts +11 -0
  55. package/src/shared/wsl.ts +139 -0
  56. package/src/tui/index.ts +26 -8
  57. package/src/tui/terminal-guard.ts +139 -0
  58. package/typeclaw.schema.json +2 -2
@@ -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,16 +1,21 @@
1
1
  import { existsSync } from 'node:fs'
2
+ import { connect, type Socket as NetSocket } from 'node:net'
2
3
 
3
- import type { Socket } from 'bun'
4
+ import { isWindows } from '@/shared'
4
5
 
5
6
  import { socketPath } from './paths'
6
7
  import type { Request, Response } from './protocol'
7
8
 
8
9
  const DEFAULT_TIMEOUT_MS = 3_000
9
10
 
10
- export async function isDaemonReachable(timeoutMs = DEFAULT_TIMEOUT_MS): Promise<boolean> {
11
- if (!existsSync(socketPath())) return false
11
+ export async function isDaemonReachable(
12
+ timeoutMs = DEFAULT_TIMEOUT_MS,
13
+ opts: Pick<SendOptions, 'socket'> = {},
14
+ ): Promise<boolean> {
15
+ const path = opts.socket ?? socketPath()
16
+ if (!isWindows() && !existsSync(path)) return false
12
17
  try {
13
- const reply = await send({ kind: 'list' }, { timeoutMs })
18
+ const reply = await send({ kind: 'list' }, { timeoutMs, socket: path })
14
19
  return reply.ok
15
20
  } catch {
16
21
  return false
@@ -58,56 +63,47 @@ export async function send(req: Request, opts: SendOptions = {}): Promise<Respon
58
63
  const path = opts.socket ?? socketPath()
59
64
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
60
65
 
61
- type State = { buf: string; resolve: (r: Response) => void }
62
- const state: State = {
63
- buf: '',
64
- resolve: () => {},
65
- }
66
+ return new Promise<Response>((resolve) => {
67
+ let buf = ''
68
+ let settled = false
69
+ let sock: NetSocket | null = null
70
+ let timer: ReturnType<typeof setTimeout> | null = null
66
71
 
67
- const replyPromise = new Promise<Response>((resolve) => {
68
- state.resolve = resolve
69
- })
72
+ const finish = (response: Response, destroy = false): void => {
73
+ if (settled) return
74
+ settled = true
75
+ if (timer) clearTimeout(timer)
76
+ if (sock) {
77
+ try {
78
+ if (destroy) sock.destroy()
79
+ else sock.end()
80
+ } catch {}
81
+ }
82
+ resolve(response)
83
+ }
70
84
 
71
- let sock: Socket<State>
72
- try {
73
- sock = await Bun.connect<State>({
74
- unix: path,
75
- socket: {
76
- data: (s, chunk) => {
77
- s.data.buf += chunk.toString('utf8')
78
- const newline = s.data.buf.indexOf('\n')
79
- if (newline < 0) return
80
- const line = s.data.buf.slice(0, newline)
81
- try {
82
- const parsed = JSON.parse(line) as Response
83
- s.data.resolve(parsed)
84
- } catch {
85
- s.data.resolve({ ok: false, reason: 'invalid response from daemon' })
86
- }
87
- s.end()
88
- },
89
- close: () => {},
90
- error: () => {
91
- state.resolve({ ok: false, reason: 'socket error' })
92
- },
93
- },
85
+ timer = setTimeout(() => finish({ ok: false, reason: `daemon ack timeout after ${timeoutMs}ms` }, true), timeoutMs)
86
+ sock = connect(path)
87
+ sock.on('connect', () => {
88
+ try {
89
+ sock?.write(`${JSON.stringify(req)}\n`)
90
+ } catch (error) {
91
+ finish({ ok: false, reason: error instanceof Error ? error.message : String(error) }, true)
92
+ }
93
+ })
94
+ sock.on('data', (chunk: Buffer) => {
95
+ buf += chunk.toString('utf8')
96
+ const newline = buf.indexOf('\n')
97
+ if (newline < 0) return
98
+ const line = buf.slice(0, newline)
99
+ try {
100
+ finish(JSON.parse(line) as Response)
101
+ } catch {
102
+ finish({ ok: false, reason: 'invalid response from daemon' }, true)
103
+ }
104
+ })
105
+ sock.on('error', (error) => {
106
+ finish({ ok: false, reason: error instanceof Error ? error.message : String(error) }, true)
94
107
  })
95
- } catch (error) {
96
- return { ok: false, reason: error instanceof Error ? error.message : String(error) }
97
- }
98
- sock.data = state
99
- sock.write(`${JSON.stringify(req)}\n`)
100
-
101
- let timer: ReturnType<typeof setTimeout> | null = null
102
- const timeoutPromise = new Promise<Response>((resolve) => {
103
- timer = setTimeout(() => resolve({ ok: false, reason: `daemon ack timeout after ${timeoutMs}ms` }), timeoutMs)
104
108
  })
105
- try {
106
- return await Promise.race([replyPromise, timeoutPromise])
107
- } finally {
108
- if (timer) clearTimeout(timer)
109
- try {
110
- sock.end()
111
- } catch {}
112
- }
113
109
  }
@@ -1,14 +1,14 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { chmod, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
3
+ import { createServer, type Server, type Socket as NetSocket } from 'node:net'
3
4
  import { join } from 'node:path'
4
5
 
5
- import type { Socket, UnixSocketListener } from 'bun'
6
-
7
6
  import type { PortForward } from '@/config'
8
7
  import { defaultDockerExec, type DockerExec } from '@/container'
9
8
  import type { PortForwardEvent } from '@/portbroker'
10
9
  import { kakaoChannelBlockSchema, lineChannelBlockSchema } from '@/secrets/schema'
11
10
  import { SecretsBackend } from '@/secrets/storage'
11
+ import { isWindows } from '@/shared'
12
12
 
13
13
  import { isDaemonReachable } from './client'
14
14
  import type { KakaoRenewalCallbacks, KakaoRenewalLogEvent } from './kakao-renewal-manager'
@@ -121,8 +121,6 @@ const MAX_HTTP_REQUEST_BYTES = 64 * 1024
121
121
  // it's already in use by some other local service.
122
122
  const STABLE_HTTP_PORT = 8974
123
123
 
124
- type ServerState = { buf: string }
125
-
126
124
  function json(response: RpcResponse, status = 200): globalThis.Response {
127
125
  return new Response(JSON.stringify(response), {
128
126
  status,
@@ -208,12 +206,54 @@ function stringifyError(error: unknown): string {
208
206
  return error instanceof Error ? error.message : String(error)
209
207
  }
210
208
 
209
+ function errorCode(error: Error): unknown {
210
+ const direct = error as Error & { code?: unknown; cause?: unknown }
211
+ if (direct.code !== undefined) return direct.code
212
+ if (direct.cause instanceof Error) return errorCode(direct.cause)
213
+ return undefined
214
+ }
215
+
216
+ async function listenOnSocket(server: Server, path: string, onWindows: boolean): Promise<void> {
217
+ await new Promise<void>((resolve, reject) => {
218
+ const onError = (error: Error): void => {
219
+ server.off('error', onError)
220
+ const code = errorCode(error)
221
+ if (code === 'EADDRINUSE' || (onWindows && error.message.includes('Failed to listen at'))) {
222
+ reject(new Error(`another typeclaw host daemon is already listening at ${path}`))
223
+ return
224
+ }
225
+ reject(error)
226
+ }
227
+ server.once('error', onError)
228
+ server.listen(path, () => {
229
+ server.off('error', onError)
230
+ resolve()
231
+ })
232
+ })
233
+ }
234
+
235
+ async function closeSocketServer(server: Server, sockets: Set<NetSocket>): Promise<void> {
236
+ await new Promise<void>((resolve) => {
237
+ try {
238
+ server.close(() => resolve())
239
+ } catch {
240
+ resolve()
241
+ }
242
+ for (const socket of sockets) {
243
+ try {
244
+ socket.destroy()
245
+ } catch {}
246
+ }
247
+ })
248
+ }
249
+
211
250
  export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
212
251
  await ensureDirs()
213
252
  const path = opts.socket ?? socketPath()
253
+ const onWindows = isWindows()
214
254
 
215
- if (existsSync(path)) {
216
- if (await isDaemonReachable(500)) {
255
+ if (!onWindows && existsSync(path)) {
256
+ if (await isDaemonReachable(500, { socket: path })) {
217
257
  throw new Error(`another typeclaw host daemon is already listening at ${path}`)
218
258
  }
219
259
  try {
@@ -488,37 +528,37 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
488
528
  }
489
529
  }
490
530
 
491
- const respond = (sock: Socket<ServerState>, response: RpcResponse): void => {
531
+ const respond = (socket: NetSocket, response: RpcResponse): void => {
492
532
  try {
493
- sock.write(`${JSON.stringify(response)}\n`)
533
+ socket.write(`${JSON.stringify(response)}\n`)
494
534
  } catch {}
495
535
  try {
496
- sock.end()
536
+ socket.end()
497
537
  } catch {}
498
538
  }
499
539
 
500
- const handleData = (sock: Socket<ServerState>, chunk: Buffer): void => {
501
- sock.data.buf += chunk.toString('utf8')
502
- if (sock.data.buf.length > MAX_REQUEST_BUFFER_BYTES) {
503
- respond(sock, { ok: false, reason: 'request exceeds buffer limit' })
540
+ const handleData = (socket: NetSocket, chunk: Buffer, state: { buf: string }): void => {
541
+ state.buf += chunk.toString('utf8')
542
+ if (state.buf.length > MAX_REQUEST_BUFFER_BYTES) {
543
+ respond(socket, { ok: false, reason: 'request exceeds buffer limit' })
504
544
  return
505
545
  }
506
- let newline = sock.data.buf.indexOf('\n')
546
+ let newline = state.buf.indexOf('\n')
507
547
  while (newline >= 0) {
508
- const line = sock.data.buf.slice(0, newline)
509
- sock.data.buf = sock.data.buf.slice(newline + 1)
548
+ const line = state.buf.slice(0, newline)
549
+ state.buf = state.buf.slice(newline + 1)
510
550
  let req: Request
511
551
  try {
512
552
  req = JSON.parse(line) as Request
513
553
  } catch {
514
- respond(sock, { ok: false, reason: 'invalid request json' })
554
+ respond(socket, { ok: false, reason: 'invalid request json' })
515
555
  return
516
556
  }
517
557
  void dispatch(req).then(
518
- (response) => respond(sock, response),
519
- (error) => respond(sock, { ok: false, reason: error instanceof Error ? error.message : String(error) }),
558
+ (response) => respond(socket, response),
559
+ (error) => respond(socket, { ok: false, reason: error instanceof Error ? error.message : String(error) }),
520
560
  )
521
- newline = sock.data.buf.indexOf('\n')
561
+ newline = state.buf.indexOf('\n')
522
562
  }
523
563
  }
524
564
 
@@ -599,25 +639,28 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
599
639
  }
600
640
 
601
641
  // Boot-time restore: replay every persisted registration into the in-memory
602
- // maps and revive portbroker for it. Runs before Bun.listen so the socket
642
+ // maps and revive portbroker for it. Runs before the IPC listener so the socket
603
643
  // is never accepting RPCs against a half-restored registry. A bad file
604
644
  // (parse error, schema mismatch) is logged-and-skipped — one corrupt
605
645
  // registration must not gate every other container's recovery.
606
646
  await restorePersistedRegistrations(applyRegistration, log, probeContainerAlive, removeRegistrationFile)
607
647
 
608
- const listener: UnixSocketListener<ServerState> = Bun.listen<ServerState>({
609
- unix: path,
610
- socket: {
611
- open: (sock) => {
612
- sock.data = { buf: '' }
613
- },
614
- data: handleData,
615
- close: () => {},
616
- error: () => {},
617
- },
648
+ const sockets = new Set<NetSocket>()
649
+ const listener = createServer((socket) => {
650
+ const state = { buf: '' }
651
+ sockets.add(socket)
652
+ socket.on('data', (chunk: Buffer) => handleData(socket, chunk, state))
653
+ socket.on('close', () => sockets.delete(socket))
654
+ socket.on('error', () => {})
618
655
  })
619
- // Restrict socket to the owning user; ~/.typeclaw/run is also 0700.
620
- await chmod(path, 0o600).catch(() => {})
656
+ try {
657
+ await listenOnSocket(listener, path, onWindows)
658
+ } catch (error) {
659
+ httpServer.stop(true)
660
+ throw error
661
+ }
662
+ // Restrict POSIX sockets to the owning user; ~/.typeclaw/run is also 0700.
663
+ if (!onWindows) await chmod(path, 0o600).catch(() => {})
621
664
  log({ kind: 'daemon-listening', socket: path })
622
665
 
623
666
  const runGc = async (): Promise<void> => {
@@ -658,9 +701,7 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
658
701
  stopped = true
659
702
  log({ kind: 'daemon-stopping' })
660
703
  clearInterval(gcTimer)
661
- try {
662
- listener.stop(true)
663
- } catch {}
704
+ await closeSocketServer(listener, sockets)
664
705
  httpServer.stop(true)
665
706
  if (opts.portbroker) {
666
707
  const names = Array.from(cwds.keys())
@@ -672,9 +713,11 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
672
713
  }
673
714
  cwds.clear()
674
715
  restartTokens.clear()
675
- try {
676
- if (existsSync(path)) await unlink(path)
677
- } catch {}
716
+ if (!onWindows) {
717
+ try {
718
+ if (existsSync(path)) await unlink(path)
719
+ } catch {}
720
+ }
678
721
  },
679
722
  }
680
723
  return daemonHandle
@@ -1,6 +1,9 @@
1
+ import { createHash } from 'node:crypto'
1
2
  import { chmod, mkdir } from 'node:fs/promises'
2
- import { homedir } from 'node:os'
3
- import { join } from 'node:path'
3
+ import { homedir, userInfo } from 'node:os'
4
+ import { join, resolve } from 'node:path'
5
+
6
+ import { isWindows } from '@/shared'
4
7
 
5
8
  // Fixed in-container path where the host daemon's run dir is bind-mounted.
6
9
  // The agent uses this to reach the host daemon (e.g. for the `restart` tool).
@@ -33,9 +36,26 @@ export function logDir(): string {
33
36
  }
34
37
 
35
38
  export function socketPath(): string {
39
+ if (isWindows()) return windowsPipePath()
36
40
  return join(runDir(), SOCKET_FILE)
37
41
  }
38
42
 
43
+ function windowsPipePath(): string {
44
+ const uid =
45
+ typeof process.getuid === 'function'
46
+ ? `uid:${process.getuid()}`
47
+ : `user:${process.env.USERDOMAIN ?? ''}\\${userInfo().username}`
48
+ // Locale-invariant lowercasing: toLocaleLowerCase under e.g. tr-TR would map
49
+ // 'I' to a dotless 'ı', hashing the same path differently per process locale.
50
+ const scopedHome = resolve(homeRoot()).toLowerCase()
51
+ const hash = createHash('sha256').update(`${uid}\0${scopedHome}`).digest('hex').slice(0, 32)
52
+
53
+ // Node's net named-pipe API has no portable ACL hook. TypeClaw accepts that
54
+ // under the single-tenant dev-box model; the per-user/per-home pipe name keeps
55
+ // the pipe scoped, while the separate HTTP leg remains restart/secrets-only.
56
+ return `\\\\.\\pipe\\typeclaw-hostd-${hash}`
57
+ }
58
+
39
59
  export function pidfilePath(): string {
40
60
  return join(runDir(), 'hostd.pid')
41
61
  }
@@ -1,6 +1,8 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { open, readFile, unlink, writeFile } from 'node:fs/promises'
3
3
 
4
+ import { isWindows } from '@/shared'
5
+
4
6
  import { isDaemonReachable, send } from './client'
5
7
  import { ensureDirs, lockfilePath, logfilePath, pidfilePath, socketPath } from './paths'
6
8
  import type { HttpInfoResult, VersionResult } from './protocol'
@@ -75,6 +77,11 @@ async function requestShutdownAndWait(): Promise<boolean> {
75
77
  if (!reply.ok) return false
76
78
  const deadline = Date.now() + SHUTDOWN_TIMEOUT_MS
77
79
  while (Date.now() < deadline) {
80
+ if (isWindows()) {
81
+ if (!(await isDaemonReachable(POLL_INTERVAL_MS))) return true
82
+ await sleep(POLL_INTERVAL_MS)
83
+ continue
84
+ }
78
85
  if (!existsSync(socketPath())) return true
79
86
  await sleep(POLL_INTERVAL_MS)
80
87
  }
@@ -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) {