typeclaw 0.37.3 → 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.
- package/README.md +69 -46
- package/package.json +1 -1
- package/src/agent/compaction.ts +24 -15
- package/src/agent/session-origin.ts +101 -173
- package/src/agent/system-prompt.ts +46 -48
- package/src/bundled-plugins/memory/index.ts +24 -27
- package/src/bundled-plugins/memory/load-memory.ts +78 -35
- package/src/bundled-plugins/memory/turn-dedup.ts +32 -29
- package/src/bundled-plugins/tool-result-cap/README.md +7 -7
- package/src/bundled-plugins/tool-result-cap/index.ts +1 -1
- package/src/channels/adapters/discord-bot.ts +11 -4
- package/src/channels/adapters/mention-hints.ts +58 -0
- package/src/channels/adapters/slack-bot.ts +8 -2
- package/src/channels/continuation-willingness.ts +216 -68
- package/src/channels/router.ts +29 -3
- package/src/cli/init.ts +41 -7
- package/src/cli/qr.ts +4 -3
- package/src/cli/ui.ts +8 -4
- package/src/doctor/checks.ts +145 -2
- package/src/hostd/tailscale.ts +12 -1
- package/src/init/index.ts +35 -8
- package/src/init/run-bun-install.ts +71 -37
- package/src/inspect/transcript-view.ts +15 -2
- package/src/portbroker/hostd-client.ts +32 -6
- package/src/shared/index.ts +4 -0
- package/src/shared/platform.ts +11 -0
- package/src/shared/wsl.ts +139 -0
- package/src/tui/index.ts +26 -8
- package/src/tui/terminal-guard.ts +139 -0
- package/typeclaw.schema.json +2 -2
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(
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
112
|
-
if (platform === 'darwin') {
|
|
113
|
+
if (isMacOS()) {
|
|
113
114
|
await execFileAsync('open', [filePath])
|
|
114
115
|
return
|
|
115
116
|
}
|
|
116
|
-
if (
|
|
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.
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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 */
|
package/src/doctor/checks.ts
CHANGED
|
@@ -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
|
}
|
package/src/hostd/tailscale.ts
CHANGED
|
@@ -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 =
|
|
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 {
|
|
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
|
|
443
|
+
const startResult = await startContainer({
|
|
432
444
|
cwd,
|
|
433
445
|
preferredHostPort: port,
|
|
434
446
|
...(cliEntry !== undefined ? { cliEntry } : {}),
|
|
435
447
|
})
|
|
436
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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' })
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export type InstallResult = { ok: true } | { ok: false; reason: string }
|
|
2
2
|
|
|
3
|
+
const INSTALL_TIMEOUT_MS = 300_000
|
|
4
|
+
|
|
3
5
|
export type InstallRunnerOptions = {
|
|
4
6
|
// Append `--force` to the bun install argv to bypass the cache for
|
|
5
7
|
// `file:` / `link:` deps. Bun treats name+version of a `file:` dep as a
|
|
@@ -7,6 +9,8 @@ export type InstallRunnerOptions = {
|
|
|
7
9
|
// locally-linked typeclaw never propagate into <agent>/node_modules until
|
|
8
10
|
// either the typeclaw version is bumped or the install is forced.
|
|
9
11
|
force?: boolean
|
|
12
|
+
timeoutMs?: number
|
|
13
|
+
spawn?: typeof Bun.spawn
|
|
10
14
|
}
|
|
11
15
|
|
|
12
16
|
// Signature for the function `runInit` uses to materialize the agent folder's
|
|
@@ -19,31 +23,29 @@ export async function runBunInstall(cwd: string, opts?: InstallRunnerOptions): P
|
|
|
19
23
|
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
20
24
|
if (!bun) return { ok: false, reason: 'bun runtime not available' }
|
|
21
25
|
try {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
26
|
+
// `--linker=hoisted` sidesteps a deadlock in Bun 1.3.x's isolated linker
|
|
27
|
+
// (the default since 1.3.0). When any single package fetch fails — 401,
|
|
28
|
+
// SHA-512 mismatch, transient registry 5xx, the kind of flake that's
|
|
29
|
+
// routine on GitHub Actions shared-IP runners — the isolated linker
|
|
30
|
+
// hangs the process indefinitely instead of erroring out
|
|
31
|
+
// (oven-sh/bun#26341, oven-sh/bun#29646). `bun install` runs here over
|
|
32
|
+
// ~500 transitive packages with no lockfile, so the odds of triggering
|
|
33
|
+
// the bug are non-trivial. Hoisted is the fallback strategy bun shipped
|
|
34
|
+
// before 1.3 — slightly slower for huge monorepos, indistinguishable
|
|
35
|
+
// for an agent folder, and not affected by the bug.
|
|
36
|
+
//
|
|
37
|
+
// `--force` is conditional: it bypasses the package cache so file:/link:
|
|
38
|
+
// deps re-copy their current on-disk source into node_modules. Bun's
|
|
39
|
+
// file-dep cache is keyed on name+version, so without --force, edits to
|
|
40
|
+
// a `file:..` typeclaw never reach the container after the first install.
|
|
41
|
+
return await runTimedBunProcess({
|
|
38
42
|
cmd: opts?.force ? ['bun', 'install', '--linker=hoisted', '--force'] : ['bun', 'install', '--linker=hoisted'],
|
|
39
43
|
cwd,
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
timeoutMs: opts?.timeoutMs ?? INSTALL_TIMEOUT_MS,
|
|
45
|
+
spawn: opts?.spawn ?? bun.spawn,
|
|
46
|
+
timeoutReason: (seconds) => `bun install timed out after ${seconds}s`,
|
|
47
|
+
failureReason: (code, stderr) => `bun install exited with code ${code}: ${stderr.trim() || 'no stderr'}`,
|
|
42
48
|
})
|
|
43
|
-
const code = await proc.exited
|
|
44
|
-
if (code === 0) return { ok: true }
|
|
45
|
-
const stderr = await new Response(proc.stderr).text()
|
|
46
|
-
return { ok: false, reason: `bun install exited with code ${code}: ${stderr.trim() || 'no stderr'}` }
|
|
47
49
|
} catch (error) {
|
|
48
50
|
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
49
51
|
}
|
|
@@ -55,30 +57,62 @@ export async function runBunInstall(cwd: string, opts?: InstallRunnerOptions): P
|
|
|
55
57
|
// install` would no-op when the existing lockfile entry already satisfies
|
|
56
58
|
// an in-range spec — which is the exact regression auto-upgrade exists to
|
|
57
59
|
// prevent).
|
|
58
|
-
export type
|
|
60
|
+
export type UpdateRunnerOptions = Pick<InstallRunnerOptions, 'timeoutMs' | 'spawn'>
|
|
59
61
|
|
|
60
|
-
export
|
|
62
|
+
export type UpdateRunner = (cwd: string, pkg: string, opts?: UpdateRunnerOptions) => Promise<InstallResult>
|
|
63
|
+
|
|
64
|
+
export async function runBunUpdate(cwd: string, pkg: string, opts?: UpdateRunnerOptions): Promise<InstallResult> {
|
|
61
65
|
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
62
66
|
if (!bun) return { ok: false, reason: 'bun runtime not available' }
|
|
63
67
|
try {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
// `bun update <pkg> --latest` re-resolves <pkg> against the registry,
|
|
69
|
+
// capped by the spec in package.json. For a caret/tilde range this
|
|
70
|
+
// pulls the highest in-range version (the case `bun install` won't
|
|
71
|
+
// upgrade because the lockfile already satisfies the spec). For an
|
|
72
|
+
// exact pin it's effectively a force re-fetch of that exact version.
|
|
73
|
+
// `--linker=hoisted` for the same Bun 1.3.x deadlock reason as
|
|
74
|
+
// runBunInstall above.
|
|
75
|
+
return await runTimedBunProcess({
|
|
72
76
|
cmd: ['bun', 'update', pkg, '--latest', '--linker=hoisted'],
|
|
73
77
|
cwd,
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
timeoutMs: opts?.timeoutMs ?? INSTALL_TIMEOUT_MS,
|
|
79
|
+
spawn: opts?.spawn ?? bun.spawn,
|
|
80
|
+
timeoutReason: (seconds) => `bun update ${pkg} timed out after ${seconds}s`,
|
|
81
|
+
failureReason: (code, stderr) => `bun update ${pkg} exited with code ${code}: ${stderr.trim() || 'no stderr'}`,
|
|
76
82
|
})
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function runTimedBunProcess({
|
|
89
|
+
cmd,
|
|
90
|
+
cwd,
|
|
91
|
+
timeoutMs,
|
|
92
|
+
spawn,
|
|
93
|
+
timeoutReason,
|
|
94
|
+
failureReason,
|
|
95
|
+
}: {
|
|
96
|
+
cmd: string[]
|
|
97
|
+
cwd: string
|
|
98
|
+
timeoutMs: number
|
|
99
|
+
spawn: typeof Bun.spawn
|
|
100
|
+
timeoutReason: (seconds: number) => string
|
|
101
|
+
failureReason: (code: number, stderr: string) => string
|
|
102
|
+
}): Promise<InstallResult> {
|
|
103
|
+
const proc = spawn({ cmd, cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
104
|
+
let timedOut = false
|
|
105
|
+
const timeout = setTimeout(() => {
|
|
106
|
+
timedOut = true
|
|
107
|
+
proc.kill('SIGKILL')
|
|
108
|
+
}, timeoutMs)
|
|
109
|
+
try {
|
|
77
110
|
const code = await proc.exited
|
|
111
|
+
if (timedOut) return { ok: false, reason: timeoutReason(timeoutMs / 1000) }
|
|
78
112
|
if (code === 0) return { ok: true }
|
|
79
113
|
const stderr = await new Response(proc.stderr).text()
|
|
80
|
-
return { ok: false, reason:
|
|
81
|
-
}
|
|
82
|
-
|
|
114
|
+
return { ok: false, reason: failureReason(code, stderr) }
|
|
115
|
+
} finally {
|
|
116
|
+
clearTimeout(timeout)
|
|
83
117
|
}
|
|
84
118
|
}
|