typeclaw 0.37.4 → 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.
@@ -1,7 +1,6 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
3
  import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
4
- import { findAgentDir } from '@/init'
5
4
  import {
6
5
  fetchLiveSessions,
7
6
  listViewerItems,
@@ -20,6 +19,7 @@ import {
20
19
  import { originLabel, shortSessionId } from '@/inspect/label'
21
20
 
22
21
  import { createTailScope } from './inspect-controller'
22
+ import { requireAgentDir } from './require-agent-dir'
23
23
  import { cancel, c, errorLine, isCancel, prepareStdinForClack } from './ui'
24
24
 
25
25
  const ESC_DEBOUNCE_MS = 50
@@ -51,7 +51,7 @@ export const inspectCommand = defineCommand({
51
51
  },
52
52
  },
53
53
  async run({ args }) {
54
- const cwd = findAgentDir(process.cwd()) ?? process.cwd()
54
+ const cwd = requireAgentDir()
55
55
  const color = useColor()
56
56
  const sessionArg = typeof args.session === 'string' ? args.session : undefined
57
57
  const filterArg = typeof args.filter === 'string' ? args.filter : undefined
package/src/cli/logs.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
3
  import { logs, parseTailValue } from '@/container'
4
- import { findAgentDir } from '@/init'
5
4
 
6
5
  import { runInspectViewer } from './inspect'
6
+ import { requireAgentDir } from './require-agent-dir'
7
7
  import { c, errorLine } from './ui'
8
8
 
9
9
  export const logsCommand = defineCommand({
@@ -30,7 +30,7 @@ export const logsCommand = defineCommand({
30
30
  },
31
31
  },
32
32
  async run({ args }) {
33
- const cwd = findAgentDir(process.cwd()) ?? process.cwd()
33
+ const cwd = requireAgentDir()
34
34
 
35
35
  let tail: string | undefined
36
36
  if (args.tail !== undefined) {
@@ -0,0 +1,31 @@
1
+ import { findAgentDir, isInitialized } from '@/init'
2
+
3
+ import { errorLine } from './ui'
4
+
5
+ export type RequiredAgentDir = { ok: true; cwd: string } | { ok: false; message: string }
6
+
7
+ const NOT_AN_AGENT_FOLDER = 'TypeClaw config file not found. Run `typeclaw init` first, or cd into an agent folder.'
8
+
9
+ // Operational host-stage commands (inspect, tui, logs, stop, shell, dreams) act
10
+ // on a specific agent's container or on-disk state, so they must run from inside
11
+ // an agent folder. The `findAgentDir(...) ?? startDir` fallback every caller used
12
+ // silently degraded these commands into an agent-less view — e.g. `inspect` would
13
+ // warn "container not running" and then offer only the container-logs row — instead
14
+ // of failing. Resolving to a fail result keeps the existence check explicit.
15
+ //
16
+ // Diagnostic commands (status, doctor, role list) deliberately tolerate a missing
17
+ // agent folder and must NOT use this gate.
18
+ export function resolveRequiredAgentDir(startDir: string): RequiredAgentDir {
19
+ const cwd = findAgentDir(startDir) ?? startDir
20
+ if (!isInitialized(cwd)) return { ok: false, message: NOT_AN_AGENT_FOLDER }
21
+ return { ok: true, cwd }
22
+ }
23
+
24
+ export function requireAgentDir(startDir: string = process.cwd()): string {
25
+ const result = resolveRequiredAgentDir(startDir)
26
+ if (!result.ok) {
27
+ console.error(errorLine(result.message))
28
+ process.exit(1)
29
+ }
30
+ return result.cwd
31
+ }
package/src/cli/shell.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
3
  import { shell } from '@/container'
4
- import { findAgentDir } from '@/init'
5
4
 
5
+ import { requireAgentDir } from './require-agent-dir'
6
6
  import { c, errorLine } from './ui'
7
7
 
8
8
  export const shellCommand = defineCommand({
@@ -18,7 +18,7 @@ export const shellCommand = defineCommand({
18
18
  },
19
19
  },
20
20
  async run({ args }) {
21
- const cwd = findAgentDir(process.cwd()) ?? process.cwd()
21
+ const cwd = requireAgentDir()
22
22
 
23
23
  console.log(c.cyan(`Attaching ${args.shell} inside the container...`))
24
24
 
package/src/cli/stop.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
3
  import { stop } from '@/container'
4
- import { findAgentDir } from '@/init'
5
4
 
5
+ import { requireAgentDir } from './require-agent-dir'
6
6
  import { c, spinner } from './ui'
7
7
 
8
8
  export const stopCommand = defineCommand({
@@ -11,7 +11,7 @@ export const stopCommand = defineCommand({
11
11
  description: 'stop the agent container (host stage)',
12
12
  },
13
13
  async run() {
14
- const cwd = findAgentDir(process.cwd()) ?? process.cwd()
14
+ const cwd = requireAgentDir()
15
15
 
16
16
  const s = spinner()
17
17
  s.start('Stopping container...')
package/src/cli/tui.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
3
  import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
4
- import { findAgentDir } from '@/init'
5
4
  import { CLI_VERSION } from '@/init/cli-version'
6
5
  import { runTuiViewer } from '@/inspect'
7
6
  import { formatVersionMismatchWarning } from '@/tui'
8
7
 
9
8
  import { runInspectViewer } from './inspect'
9
+ import { requireAgentDir } from './require-agent-dir'
10
10
  import { errorLine } from './ui'
11
11
 
12
12
  export const tui = defineCommand({
@@ -27,9 +27,22 @@ export const tui = defineCommand({
27
27
  },
28
28
  },
29
29
  async run({ args }) {
30
- const cwd = findAgentDir(process.cwd()) ?? process.cwd()
31
- const resolveUrl: () => Promise<string> =
32
- args.url !== undefined ? async () => args.url as string : () => defaultUrl(cwd)
30
+ // An explicit --url targets an agent over the wire and needs no local agent
31
+ // folder only the default-URL discovery and the esc-detach picker read
32
+ // local state. Require an agent folder only when --url is absent, mirroring
33
+ // how reload/cron/role claim gate their default-target path, not the whole
34
+ // command.
35
+ const explicitUrl = typeof args.url === 'string' ? args.url : undefined
36
+ let cwd: string | undefined
37
+ let resolveUrl: () => Promise<string>
38
+ if (explicitUrl === undefined) {
39
+ const agentDir = requireAgentDir()
40
+ cwd = agentDir
41
+ resolveUrl = () => defaultUrl(agentDir)
42
+ } else {
43
+ cwd = undefined
44
+ resolveUrl = async () => explicitUrl
45
+ }
33
46
 
34
47
  const result = await runTuiViewer({
35
48
  resolveUrl,
@@ -45,8 +58,9 @@ export const tui = defineCommand({
45
58
  // can pick another session or the container logs — `tui` is just a deep-link
46
59
  // into the session viewer, pre-opened on the live session. allowWritable
47
60
  // is false because detaching ended the live session, so no row may be
48
- // offered as a writable "live TUI" anymore.
49
- if (result.ok && result.escToPicker === true) {
61
+ // offered as a writable "live TUI" anymore. A --url-only run has no local
62
+ // agent folder to browse, so it exits instead of opening the local picker.
63
+ if (result.ok && result.escToPicker === true && cwd !== undefined) {
50
64
  const viewerExit = await runInspectViewer({ cwd, allowWritable: false })
51
65
  process.exit(viewerExit)
52
66
  return
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto'
1
2
  import { basename, resolve } from 'node:path'
2
3
 
3
4
  export type DockerExecResult = { exitCode: number; stdout: string; stderr: string }
@@ -249,8 +250,25 @@ export function imageTagFromCwd(cwd: string): string {
249
250
  }
250
251
 
251
252
  // Docker container names must match [a-zA-Z0-9][a-zA-Z0-9_.-]*.
253
+ //
254
+ // Non-ASCII names (Korean/CJK/Cyrillic/accented Latin — the common Windows case
255
+ // where the profile folder is a localized display name, e.g. C:\Users\사용자\봇)
256
+ // have every out-of-charset character collapsed to a dash, so distinct folders
257
+ // reduce to the SAME string: '봇' and '집' both → 'tc--'. The container name keys
258
+ // hostd registration and the secrets key path, so that collision is silent
259
+ // host-side state clobbering, not cosmetics. For any name carrying a non-ASCII
260
+ // char we append a deterministic hash of the original (cf. makeUntitledSlug in
261
+ // the memory plugin) to keep distinct folders distinct; surviving ASCII stays a
262
+ // readable prefix. ASCII-only names take the original branch — never renamed.
252
263
  function sanitizeContainerName(name: string): string {
253
264
  const cleaned = name.replace(/[^a-zA-Z0-9_.-]/g, '-')
265
+ if (/[^\u0000-\u007f]/.test(name)) {
266
+ const hash = createHash('sha256').update(name).digest('hex').slice(0, 8)
267
+ const remnant = cleaned.replace(/-+/g, '-').replace(/^-+|-+$/g, '')
268
+ if (remnant === '') return `tc-${hash}`
269
+ const base = /^[a-zA-Z0-9]/.test(remnant) ? remnant : `tc-${remnant}`
270
+ return `${base}-${hash}`
271
+ }
254
272
  if (cleaned === '' || !/^[a-zA-Z0-9]/.test(cleaned)) {
255
273
  return `tc-${cleaned || 'agent'}`
256
274
  }
@@ -671,7 +671,7 @@ export async function planStart({
671
671
  // so mounting an empty cache would only invite a confusing local_files_only
672
672
  // miss if something inside the container reached for the model anyway.
673
673
  if (agentUsesVector(cwd)) {
674
- runArgs.push('-v', `${homeRoot()}/models:/opt/models:ro`)
674
+ runArgs.push('-v', `${join(homeRoot(), 'models')}:/opt/models:ro`)
675
675
  runArgs.push('-e', 'TYPECLAW_MODEL_CACHE=/opt/models')
676
676
  }
677
677
 
@@ -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,4 @@
1
- import { join, resolve } from 'node:path'
1
+ import { join, posix, resolve } from 'node:path'
2
2
 
3
3
  import { loginFlow as upstreamLoginFlow } from 'agent-messenger/kakaotalk'
4
4
 
@@ -34,7 +34,7 @@ export type LoginFlowOptions = Parameters<LoginFlowFn>[0]
34
34
  export type LoginFlowResult = Awaited<ReturnType<LoginFlowFn>>
35
35
 
36
36
  export function kakaotalkConfigDir(agentDir: string): string {
37
- return join(agentDir, 'workspace', '.agent-messenger')
37
+ return posix.join(agentDir, 'workspace', '.agent-messenger')
38
38
  }
39
39
 
40
40
  export function kakaotalkSecretsPath(agentDir: string): string {
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
- import { join } from 'node:path'
3
+ import { join, posix } from 'node:path'
4
4
 
5
5
  import { GITKEEP_FILE, PACKAGES_DIR } from './paths'
6
6
 
@@ -33,7 +33,7 @@ export async function refreshPackageJson(cwd: string): Promise<PackageJsonRefres
33
33
  if (updated) changed.push(PACKAGE_FILE)
34
34
  }
35
35
 
36
- const gitkeepRel = join(PACKAGES_DIR, GITKEEP_FILE)
36
+ const gitkeepRel = posix.join(PACKAGES_DIR, GITKEEP_FILE)
37
37
  const gitkeepPath = join(cwd, gitkeepRel)
38
38
  if (!existsSync(gitkeepPath)) {
39
39
  await mkdir(join(cwd, PACKAGES_DIR), { recursive: true })
@@ -61,8 +61,7 @@ async function loadLocal(entry: string, agentDir: string): Promise<ResolvedPlugi
61
61
  if (!existsSync(resolved)) {
62
62
  throw new PluginNotFoundError(entry, `plugin path does not exist: ${entry} (resolved to ${resolved})`)
63
63
  }
64
- const url = pathToFileURL(resolved).href
65
- const mod = (await import(url)) as { default?: unknown }
64
+ const mod = (await import(toModuleSpecifier(resolved))) as { default?: unknown }
66
65
  const defined = expectDefined(mod, entry)
67
66
  const name = basename(resolved).replace(/\.(ts|tsx|js|mjs|cjs)$/i, '')
68
67
  return { name, version: undefined, source: entry, defined }
@@ -108,10 +107,10 @@ async function loadNpm(entry: string, agentDir: string): Promise<ResolvedPlugin>
108
107
  // located on disk; the else branch lets Bun's resolver read `exports` maps.
109
108
  let importTarget: string
110
109
  if (entryPath !== null) {
111
- importTarget = pathToFileURL(entryPath).href
110
+ importTarget = toModuleSpecifier(entryPath)
112
111
  } else {
113
112
  try {
114
- importTarget = Bun.resolveSync(packageName, agentDir)
113
+ importTarget = toModuleSpecifier(Bun.resolveSync(packageName, agentDir))
115
114
  } catch (err) {
116
115
  throw new PluginNotFoundError(entry, `cannot resolve plugin "${entry}": ${describeError(err)}`, { cause: err })
117
116
  }
@@ -151,6 +150,10 @@ function describeError(err: unknown): string {
151
150
  return err instanceof Error ? err.message : String(err)
152
151
  }
153
152
 
153
+ function toModuleSpecifier(target: string): string {
154
+ return isAbsolute(target) ? pathToFileURL(target).href : target
155
+ }
156
+
154
157
  function findPackageJson(entry: string, agentDir: string): string | null {
155
158
  const PACKAGE_JSON = 'package.json'
156
159
  let cur = agentDir