typeclaw 0.37.2 → 0.37.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -47
- 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 +265 -53
- package/src/channels/router.ts +105 -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/channels/router.ts
CHANGED
|
@@ -97,6 +97,11 @@ export const MAX_DEBOUNCE_MS = 4000
|
|
|
97
97
|
export const HOT_THRESHOLD_MS = 3000
|
|
98
98
|
export const MAX_CONSECUTIVE_ABORTS = 3
|
|
99
99
|
export const CONTEXT_BUFFER_SIZE = 20
|
|
100
|
+
// Observed ("Recent context") messages are awareness-only and replayed in full
|
|
101
|
+
// on every turn (uncached), so one long paste would otherwise re-bloat every
|
|
102
|
+
// subsequent turn until it ages out. Cap each observed message's text; the
|
|
103
|
+
// addressed current message is never capped (it's the actual request).
|
|
104
|
+
export const OBSERVED_MESSAGE_MAX_CHARS = 800
|
|
100
105
|
// Discord's typing indicator expires after ~10s; an 8s heartbeat keeps it
|
|
101
106
|
// continuously visible while we debounce + generate without spamming the API.
|
|
102
107
|
export const TYPING_HEARTBEAT_MS = 8000
|
|
@@ -279,6 +284,27 @@ export const WILLINGNESS_NUDGE = [
|
|
|
279
284
|
'',
|
|
280
285
|
'---',
|
|
281
286
|
].join('\n')
|
|
287
|
+
// Injected when a `channel_send` ack tripped continuation-willingness, the model
|
|
288
|
+
// did fresh work after it, then ended on an EMPTY `stop` leaf — the answer was
|
|
289
|
+
// computed but never sent (the Kimi/Fireworks empty-completion flake). Distinct
|
|
290
|
+
// from WILLINGNESS_NUDGE: that path is a `channel_reply` that ended the turn and
|
|
291
|
+
// needs `continue: true`; this path is a `channel_send` (which never ends the
|
|
292
|
+
// turn) whose follow-up degenerated, so the model just needs to emit the reply it
|
|
293
|
+
// already worked out. Shares MAX_WILLINGNESS_NUDGES so a turn can't double-nudge.
|
|
294
|
+
export const SEND_WILLINGNESS_NUDGE = [
|
|
295
|
+
'---',
|
|
296
|
+
'**[SYSTEM MESSAGE — not from a human]**',
|
|
297
|
+
'',
|
|
298
|
+
'You said you would keep working this turn and did the work, but the turn ended',
|
|
299
|
+
'without sending the result — nothing reached the channel after your last',
|
|
300
|
+
'message. This is an automated signal from the channel router, not a message',
|
|
301
|
+
'from anyone in the chat. **Do not acknowledge or reply to this notice itself.**',
|
|
302
|
+
'',
|
|
303
|
+
'Send the answer you just worked out now via your channel send tool. If you',
|
|
304
|
+
'genuinely have nothing to report, reply with `NO_REPLY`.',
|
|
305
|
+
'',
|
|
306
|
+
'---',
|
|
307
|
+
].join('\n')
|
|
282
308
|
// Rolling window for outbound send-rate telemetry. 5s matches Discord's
|
|
283
309
|
// rate-limit shape (5 msg / 5 s / channel) and comfortably covers Slack's
|
|
284
310
|
// 1 msg/s sustained. The window is observational; exceeding the burst
|
|
@@ -3387,6 +3413,43 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
3387
3413
|
// landed — suppress it, as before.
|
|
3388
3414
|
if (live.successfulChannelSends > successfulSendsBeforePrompt) {
|
|
3389
3415
|
maybeNudgeContinuationWillingness(live)
|
|
3416
|
+
|
|
3417
|
+
// A `channel_send` ack that promised to keep working, fresh post-ack work,
|
|
3418
|
+
// then an EMPTY `stop` leaf: the model computed the answer in its reasoning
|
|
3419
|
+
// / tool results but never sent it (the Kimi/Fireworks empty-completion
|
|
3420
|
+
// flake). `maybeNudgeContinuationWillingness` above can't catch this — it
|
|
3421
|
+
// reads `lastTerminalReplyAbort`, which only a `channel_reply` sets;
|
|
3422
|
+
// `channel_send` keeps the turn alive and stamps nothing. And the
|
|
3423
|
+
// stranded-toolUse retry below requires `source !== 'leaf'`, but an empty
|
|
3424
|
+
// `stop` leaf recovers as `source: 'leaf'`, so this shape would otherwise
|
|
3425
|
+
// fall straight through to the `endsWithNoReplySignal('')` → `no_reply`
|
|
3426
|
+
// classification. Discriminator (all on existing state, zero false positives
|
|
3427
|
+
// measured across the session corpus): a send landed AND the just-sent text
|
|
3428
|
+
// trips the precision-tuned willingness detector AND the turn-end leaf is a
|
|
3429
|
+
// FRESH empty `stop` (different entry than the ack's leaf — so the model did
|
|
3430
|
+
// post-ack work, not an ack-then-await-user stop). Bounded by
|
|
3431
|
+
// MAX_WILLINGNESS_NUDGES (shared with the reply path); on exhaustion post the
|
|
3432
|
+
// fallback rather than going silent, mirroring the stranded-toolUse path.
|
|
3433
|
+
// Gated on an empty `promptQueue` (like maybeNudgeContinuationWillingness): a
|
|
3434
|
+
// real inbound that coalesced into the just-finished prompt will be answered
|
|
3435
|
+
// by the next drain pass, and drain() splices pending reminders into that
|
|
3436
|
+
// batch — so injecting a stale recovery nudge would prepend it to a live user
|
|
3437
|
+
// message. Skip the nudge AND the fallback in that case and let the trailing
|
|
3438
|
+
// recovery below run; the queued inbound supersedes this turn's silence.
|
|
3439
|
+
if (live.promptQueue.length === 0 && live.currentTurnAuthorId !== null && isEmptyStopAfterWillingnessAck(live)) {
|
|
3440
|
+
if (live.willingnessNudges < MAX_WILLINGNESS_NUDGES) {
|
|
3441
|
+
live.willingnessNudges++
|
|
3442
|
+
logger.warn(
|
|
3443
|
+
`[channels] ${live.keyId} send_willingness_nudge attempt=${live.willingnessNudges}/${MAX_WILLINGNESS_NUDGES} ` +
|
|
3444
|
+
`cause=empty_stop_after_send_ack`,
|
|
3445
|
+
)
|
|
3446
|
+
live.pendingSystemReminders.push(SEND_WILLINGNESS_NUDGE)
|
|
3447
|
+
} else {
|
|
3448
|
+
await postEmptyTurnFallback('empty_stop_after_send_ack_nudges_exhausted')
|
|
3449
|
+
}
|
|
3450
|
+
return
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3390
3453
|
const trailing = recoverableAssistantText(live.session)
|
|
3391
3454
|
if (trailing === null || trailing.source !== 'leaf') {
|
|
3392
3455
|
// A `continue: true` status reply landed, then the turn stranded on an
|
|
@@ -4404,7 +4467,7 @@ function composeTurnPrompt(
|
|
|
4404
4467
|
if (observed.length > 0) {
|
|
4405
4468
|
parts.push('## Recent context (not addressed to you, for awareness only)')
|
|
4406
4469
|
for (const o of observed) {
|
|
4407
|
-
parts.push(formatInboundPromptLines(o, adapter))
|
|
4470
|
+
parts.push(formatInboundPromptLines(o, adapter, OBSERVED_MESSAGE_MAX_CHARS))
|
|
4408
4471
|
}
|
|
4409
4472
|
parts.push('')
|
|
4410
4473
|
}
|
|
@@ -4463,10 +4526,22 @@ function formatAuthorLine(
|
|
|
4463
4526
|
authorName: string,
|
|
4464
4527
|
authorIsBot: boolean,
|
|
4465
4528
|
text: string,
|
|
4529
|
+
maxChars?: number,
|
|
4466
4530
|
): string {
|
|
4467
4531
|
const tag = authorIsBot ? ' [bot]' : ''
|
|
4468
4532
|
const stamp = ts > 0 ? `[${new Date(ts).toISOString()}] ` : ''
|
|
4469
|
-
return `${stamp}${formatAuthorReference(adapter, authorId, authorName)} (${authorName})${tag}: ${text}`
|
|
4533
|
+
return `${stamp}${formatAuthorReference(adapter, authorId, authorName)} (${authorName})${tag}: ${capObservedText(text, maxChars)}`
|
|
4534
|
+
}
|
|
4535
|
+
|
|
4536
|
+
// Cap by whole code points so truncation never splits a surrogate pair (emoji,
|
|
4537
|
+
// astral-plane chars) into a dangling half. `text.length` (UTF-16 code units) is
|
|
4538
|
+
// a cheap upper bound on the code-point count, so a string already within the
|
|
4539
|
+
// cap skips the array build.
|
|
4540
|
+
function capObservedText(text: string, maxChars: number | undefined): string {
|
|
4541
|
+
if (maxChars === undefined || text.length <= maxChars) return text
|
|
4542
|
+
const points = Array.from(text)
|
|
4543
|
+
if (points.length <= maxChars) return text
|
|
4544
|
+
return `${points.slice(0, maxChars).join('')} […truncated]`
|
|
4470
4545
|
}
|
|
4471
4546
|
|
|
4472
4547
|
function formatInboundPromptLines(
|
|
@@ -4479,10 +4554,19 @@ function formatInboundPromptLines(
|
|
|
4479
4554
|
referenceContext?: InboundReferenceContext
|
|
4480
4555
|
},
|
|
4481
4556
|
adapter: AdapterId,
|
|
4557
|
+
maxTextChars?: number,
|
|
4482
4558
|
): string {
|
|
4483
4559
|
const lines = inbound.referenceContext?.sources.map(renderQuoteAnchor) ?? []
|
|
4484
4560
|
lines.push(
|
|
4485
|
-
formatAuthorLine(
|
|
4561
|
+
formatAuthorLine(
|
|
4562
|
+
inbound.ts,
|
|
4563
|
+
adapter,
|
|
4564
|
+
inbound.authorId,
|
|
4565
|
+
inbound.authorName,
|
|
4566
|
+
inbound.authorIsBot,
|
|
4567
|
+
inbound.text,
|
|
4568
|
+
maxTextChars,
|
|
4569
|
+
),
|
|
4486
4570
|
)
|
|
4487
4571
|
return lines.join('\n')
|
|
4488
4572
|
}
|
|
@@ -5021,6 +5105,24 @@ function leafIsStrandedToolUse(session: AgentSession): boolean {
|
|
|
5021
5105
|
return false
|
|
5022
5106
|
}
|
|
5023
5107
|
|
|
5108
|
+
// True when the turn-end leaf is a FRESH empty `stop` (no text, no tool call,
|
|
5109
|
+
// distinct from the leaf in place at the last successful send) AND the most
|
|
5110
|
+
// recent send to this target was a continuation-willingness ack. This is the
|
|
5111
|
+
// `channel_send` analogue of the `channel_reply` willingness path: the model
|
|
5112
|
+
// acked "I'll check…", did post-ack work, then the follow-up came back as a
|
|
5113
|
+
// clean empty completion that would otherwise be read as a deliberate `NO_REPLY`.
|
|
5114
|
+
// The fresh-leaf check (`!== lastSendLeafId`) is what separates this degeneration
|
|
5115
|
+
// from a legitimate ack-then-stop where the model meant to wait for the user.
|
|
5116
|
+
function isEmptyStopAfterWillingnessAck(live: LiveSession): boolean {
|
|
5117
|
+
const leaf = live.session.sessionManager.getLeafEntry()
|
|
5118
|
+
if (!leaf || leaf.type !== 'message' || leaf.message.role !== 'assistant') return false
|
|
5119
|
+
if (leaf.message.stopReason !== 'stop') return false
|
|
5120
|
+
if (hasToolCall(leaf.message) || visibleAssistantText(leaf.message).trim() !== '') return false
|
|
5121
|
+
if (leaf.id === live.lastSendLeafId) return false
|
|
5122
|
+
const ackText = live.lastSentText.get(consecutiveSendKey(live.key.chat, live.key.thread))
|
|
5123
|
+
return ackText !== undefined && detectContinuationWillingness(ackText)
|
|
5124
|
+
}
|
|
5125
|
+
|
|
5024
5126
|
function visibleAssistantText(message: AssistantMessage): string {
|
|
5025
5127
|
return message.content
|
|
5026
5128
|
.filter((block) => block.type === 'text')
|
package/src/cli/init.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
hasExistingOAuthCredentials,
|
|
40
40
|
isDirectoryNonEmpty,
|
|
41
41
|
isHatched,
|
|
42
|
+
isInitialized,
|
|
42
43
|
readExistingProviderApiKey,
|
|
43
44
|
runInit,
|
|
44
45
|
type GithubInitCredentials,
|
|
@@ -139,18 +140,22 @@ export const init = defineCommand({
|
|
|
139
140
|
if (existingAgent !== null && existingAgent !== cwd) {
|
|
140
141
|
console.error(
|
|
141
142
|
errorLine(
|
|
142
|
-
`Refusing to init: a TypeClaw agent already exists at ${existingAgent}. Nested agents are not supported.`,
|
|
143
|
+
`Refusing to init: a TypeClaw agent already exists at ${existingAgent}. Nested agents are not supported. Run init from a directory that is not inside an existing agent.`,
|
|
143
144
|
),
|
|
144
145
|
)
|
|
145
146
|
process.exit(1)
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
if (await isHatched(cwd)) {
|
|
149
|
-
console.error(
|
|
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' })
|