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.
- package/package.json +1 -1
- package/src/agent/doctor.ts +6 -1
- package/src/agent/subagents.ts +146 -14
- package/src/agent/todo/scope.ts +4 -2
- package/src/agent/tools/channel-reply.ts +7 -9
- package/src/bundled-plugins/memory/index.ts +9 -6
- package/src/bundled-plugins/memory/load-memory.ts +16 -2
- package/src/bundled-plugins/memory/slug.ts +19 -0
- package/src/bundled-plugins/security/policies/private-surface-read.ts +4 -1
- package/src/channels/adapters/github/inbound.ts +68 -43
- package/src/channels/adapters/github/index.ts +57 -9
- package/src/channels/adapters/github/recover-failed-deliveries.ts +270 -0
- package/src/channels/adapters/kakaotalk.ts +5 -1
- package/src/channels/adapters/mention-hints.ts +17 -0
- package/src/channels/router.ts +120 -12
- package/src/cli/dreams.ts +2 -2
- package/src/cli/inspect.ts +2 -2
- package/src/cli/logs.ts +2 -2
- package/src/cli/require-agent-dir.ts +31 -0
- package/src/cli/shell.ts +2 -2
- package/src/cli/stop.ts +2 -2
- package/src/cli/tui.ts +20 -6
- package/src/container/shared.ts +18 -0
- package/src/container/start.ts +1 -1
- package/src/hostd/client.ts +48 -52
- package/src/hostd/daemon.ts +82 -39
- package/src/hostd/paths.ts +22 -2
- package/src/hostd/spawn.ts +7 -0
- package/src/init/kakaotalk-auth.ts +2 -2
- package/src/init/packagejson.ts +2 -2
- package/src/plugin/loader.ts +7 -4
- package/src/sandbox/session-tmp.ts +6 -1
- package/src/secrets/export-claude-credentials-file.ts +2 -2
package/src/cli/inspect.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
package/src/container/shared.ts
CHANGED
|
@@ -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
|
}
|
package/src/container/start.ts
CHANGED
|
@@ -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()}
|
|
674
|
+
runArgs.push('-v', `${join(homeRoot(), 'models')}:/opt/models:ro`)
|
|
675
675
|
runArgs.push('-e', 'TYPECLAW_MODEL_CACHE=/opt/models')
|
|
676
676
|
}
|
|
677
677
|
|
package/src/hostd/client.ts
CHANGED
|
@@ -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
|
|
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(
|
|
11
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
sock
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
},
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
}
|
package/src/hostd/daemon.ts
CHANGED
|
@@ -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 = (
|
|
531
|
+
const respond = (socket: NetSocket, response: RpcResponse): void => {
|
|
492
532
|
try {
|
|
493
|
-
|
|
533
|
+
socket.write(`${JSON.stringify(response)}\n`)
|
|
494
534
|
} catch {}
|
|
495
535
|
try {
|
|
496
|
-
|
|
536
|
+
socket.end()
|
|
497
537
|
} catch {}
|
|
498
538
|
}
|
|
499
539
|
|
|
500
|
-
const handleData = (
|
|
501
|
-
|
|
502
|
-
if (
|
|
503
|
-
respond(
|
|
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 =
|
|
546
|
+
let newline = state.buf.indexOf('\n')
|
|
507
547
|
while (newline >= 0) {
|
|
508
|
-
const line =
|
|
509
|
-
|
|
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(
|
|
554
|
+
respond(socket, { ok: false, reason: 'invalid request json' })
|
|
515
555
|
return
|
|
516
556
|
}
|
|
517
557
|
void dispatch(req).then(
|
|
518
|
-
(response) => respond(
|
|
519
|
-
(error) => respond(
|
|
558
|
+
(response) => respond(socket, response),
|
|
559
|
+
(error) => respond(socket, { ok: false, reason: error instanceof Error ? error.message : String(error) }),
|
|
520
560
|
)
|
|
521
|
-
newline =
|
|
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
|
|
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
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
620
|
-
|
|
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
|
-
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
716
|
+
if (!onWindows) {
|
|
717
|
+
try {
|
|
718
|
+
if (existsSync(path)) await unlink(path)
|
|
719
|
+
} catch {}
|
|
720
|
+
}
|
|
678
721
|
},
|
|
679
722
|
}
|
|
680
723
|
return daemonHandle
|
package/src/hostd/paths.ts
CHANGED
|
@@ -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
|
}
|
package/src/hostd/spawn.ts
CHANGED
|
@@ -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 {
|
package/src/init/packagejson.ts
CHANGED
|
@@ -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 })
|
package/src/plugin/loader.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|