typeclaw 0.37.3 → 0.37.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +69 -46
  2. package/package.json +1 -1
  3. package/src/agent/compaction.ts +24 -15
  4. package/src/agent/doctor.ts +6 -1
  5. package/src/agent/session-origin.ts +101 -173
  6. package/src/agent/subagents.ts +146 -14
  7. package/src/agent/system-prompt.ts +46 -48
  8. package/src/agent/todo/scope.ts +4 -2
  9. package/src/agent/tools/channel-reply.ts +7 -9
  10. package/src/bundled-plugins/memory/index.ts +33 -33
  11. package/src/bundled-plugins/memory/load-memory.ts +92 -35
  12. package/src/bundled-plugins/memory/slug.ts +19 -0
  13. package/src/bundled-plugins/memory/turn-dedup.ts +32 -29
  14. package/src/bundled-plugins/security/policies/private-surface-read.ts +4 -1
  15. package/src/bundled-plugins/tool-result-cap/README.md +7 -7
  16. package/src/bundled-plugins/tool-result-cap/index.ts +1 -1
  17. package/src/channels/adapters/discord-bot.ts +11 -4
  18. package/src/channels/adapters/github/inbound.ts +68 -43
  19. package/src/channels/adapters/github/index.ts +57 -9
  20. package/src/channels/adapters/github/recover-failed-deliveries.ts +270 -0
  21. package/src/channels/adapters/kakaotalk.ts +5 -1
  22. package/src/channels/adapters/mention-hints.ts +75 -0
  23. package/src/channels/adapters/slack-bot.ts +8 -2
  24. package/src/channels/continuation-willingness.ts +216 -68
  25. package/src/channels/router.ts +149 -15
  26. package/src/cli/dreams.ts +2 -2
  27. package/src/cli/init.ts +41 -7
  28. package/src/cli/inspect.ts +2 -2
  29. package/src/cli/logs.ts +2 -2
  30. package/src/cli/qr.ts +4 -3
  31. package/src/cli/require-agent-dir.ts +31 -0
  32. package/src/cli/shell.ts +2 -2
  33. package/src/cli/stop.ts +2 -2
  34. package/src/cli/tui.ts +20 -6
  35. package/src/cli/ui.ts +8 -4
  36. package/src/container/shared.ts +18 -0
  37. package/src/container/start.ts +1 -1
  38. package/src/doctor/checks.ts +145 -2
  39. package/src/hostd/client.ts +48 -52
  40. package/src/hostd/daemon.ts +82 -39
  41. package/src/hostd/paths.ts +22 -2
  42. package/src/hostd/spawn.ts +7 -0
  43. package/src/hostd/tailscale.ts +12 -1
  44. package/src/init/index.ts +35 -8
  45. package/src/init/kakaotalk-auth.ts +2 -2
  46. package/src/init/packagejson.ts +2 -2
  47. package/src/init/run-bun-install.ts +71 -37
  48. package/src/inspect/transcript-view.ts +15 -2
  49. package/src/plugin/loader.ts +7 -4
  50. package/src/portbroker/hostd-client.ts +32 -6
  51. package/src/sandbox/session-tmp.ts +6 -1
  52. package/src/secrets/export-claude-credentials-file.ts +2 -2
  53. package/src/shared/index.ts +4 -0
  54. package/src/shared/platform.ts +11 -0
  55. package/src/shared/wsl.ts +139 -0
  56. package/src/tui/index.ts +26 -8
  57. package/src/tui/terminal-guard.ts +139 -0
  58. package/typeclaw.schema.json +2 -2
@@ -24,6 +24,7 @@ import type { HookBus } from '@/plugin'
24
24
  import { extractClaimCode } from '@/role-claim'
25
25
  import type { Stream } from '@/stream'
26
26
 
27
+ import { extractMentionedUserIds } from './adapters/mention-hints'
27
28
  import { formatChannelCommandHelp } from './commands'
28
29
  import { detectContinuationWillingness } from './continuation-willingness'
29
30
  import {
@@ -97,6 +98,11 @@ export const MAX_DEBOUNCE_MS = 4000
97
98
  export const HOT_THRESHOLD_MS = 3000
98
99
  export const MAX_CONSECUTIVE_ABORTS = 3
99
100
  export const CONTEXT_BUFFER_SIZE = 20
101
+ // Observed ("Recent context") messages are awareness-only and replayed in full
102
+ // on every turn (uncached), so one long paste would otherwise re-bloat every
103
+ // subsequent turn until it ages out. Cap each observed message's text; the
104
+ // addressed current message is never capped (it's the actual request).
105
+ export const OBSERVED_MESSAGE_MAX_CHARS = 800
100
106
  // Discord's typing indicator expires after ~10s; an 8s heartbeat keeps it
101
107
  // continuously visible while we debounce + generate without spamming the API.
102
108
  export const TYPING_HEARTBEAT_MS = 8000
@@ -106,13 +112,18 @@ export const TYPING_HEARTBEAT_MS = 8000
106
112
  // turns a temporary status into a permanent-looking artifact.
107
113
  //
108
114
  // The cap is measured from `live.typingStartedAt`, which is refreshed by
109
- // two signals of life (see `bumpTypingActivity`):
115
+ // these signals of life (see `bumpTypingActivity`):
110
116
  // 1. Each new `drain()` iteration (a new turn is starting).
111
117
  // 2. Each `tool_execution_end` from the agent session (a tool just
112
118
  // completed — the prompt is progressing, not stuck).
113
- // A 2-minute bash command that emits no intermediate events still trips
114
- // the cap, but a chatty agent running long tools stays under it
115
- // indefinitely. The cap exists to catch *silence*, not duration.
119
+ // 3. Each streaming token (`message_update` carrying a `text_delta` or
120
+ // `thinking_delta`) the model is actively generating, even on a
121
+ // pure-text reply that calls no tools.
122
+ // Signal 3 is what keeps a long conversational reply (no tool calls, just
123
+ // minutes of streamed text or extended thinking) under the cap: without it,
124
+ // such a turn emits no `tool_execution_end` and the indicator was paused
125
+ // mid-generation. A genuinely stuck model call — no tokens, no tools — still
126
+ // trips the cap. The cap exists to catch *silence*, not duration.
116
127
  export const MAX_TYPING_HEARTBEAT_MS = 2 * 60 * 1000
117
128
 
118
129
  // Idle GC: a LiveSession whose `lastInboundAt` is older than
@@ -255,6 +266,28 @@ export const EMPTY_TURN_RETRY_NUDGE = [
255
266
  // drop so the human is never left staring at dead air after a degenerate turn.
256
267
  export const EMPTY_TURN_FALLBACK_TEXT =
257
268
  "⚠️ I got stuck putting together a reply and couldn't finish. Could you rephrase or try again?"
269
+ // Distinct from EMPTY_TURN_RETRY_NUDGE: that one diagnoses budget exhaustion
270
+ // ("ran out of output budget"), which is FALSE for a clean `stop` with empty
271
+ // text. This nudge names the real failure — a turn that ended sending nothing
272
+ // to a message addressed to the agent in a one-on-one conversation — and steers
273
+ // the model to either answer or record the silence explicitly (skip_response /
274
+ // NO_REPLY) rather than ending empty again.
275
+ export const COLD_START_REPLY_NUDGE = [
276
+ '---',
277
+ '**[SYSTEM MESSAGE — not from a human]**',
278
+ '',
279
+ 'Your previous turn ended without sending anything, but the last message was',
280
+ 'addressed to you in a direct, one-on-one conversation — ending silent there',
281
+ 'reads as ignoring the person. This is an automated signal from the channel',
282
+ 'router, not a message from anyone in the chat. **Do not acknowledge or reply',
283
+ 'to this notice itself.**',
284
+ '',
285
+ 'Answer the last user message now via your channel reply tool. If you truly',
286
+ 'have nothing to add, call `skip_response({ reason })` (preferred) or end with',
287
+ 'exactly `NO_REPLY` so the silence is recorded — do not just end empty.',
288
+ '',
289
+ '---',
290
+ ].join('\n')
258
291
  // At most one continuation nudge per logical turn. Stricter than the empty-turn
259
292
  // retry budget (2) because the turn ALREADY delivered a user-facing reply — this
260
293
  // is a one-shot correction offer, not recovery from no output.
@@ -767,6 +800,20 @@ type LiveSession = {
767
800
  // decision used, so the prompt nudge and sticky suppression agree on
768
801
  // "is this a multi-human group". Read by composeTurnPrompt().
769
802
  multiHumanGroup: boolean
803
+ // True when this live session was born from a cold-start (no persisted
804
+ // session existed — first contact or a stale-rollover after long idle), as
805
+ // opposed to rehydrating an existing session. Combined with `turnSeq === 0`
806
+ // it pinpoints the very first prompt of a freshly woken session.
807
+ createdFromColdStart: boolean
808
+ // Set in route() when the FIRST turn of a cold-start session engages via the
809
+ // solo-human "answer everything" fallback (not an explicit mention/reply/DM,
810
+ // not a multi-human group). Read by validateChannelTurn: a BARE-EMPTY stop on
811
+ // such a turn is a model whiff on a direct one-on-one question, not deliberate
812
+ // silence, so it earns an empty-turn retry instead of a silent no_reply.
813
+ // Recomputed on every engage, so it self-clears once turnSeq leaves the first
814
+ // turn; explicit NO_REPLY / skip_response and any turn that already sent stay
815
+ // on the historical silent path.
816
+ coldStartSoloFallbackTurnActive: boolean
770
817
  membershipFetch: Promise<MembershipCount | null> | null
771
818
  // Provider soft-error (`stopReason: 'error'`) captured during the current
772
819
  // turn, deferred to turn-end. Upstream surfaces transient errors (e.g.
@@ -1668,6 +1715,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1668
1715
  consecutiveEngagedPeerBotTurns: 0,
1669
1716
  loopGuardActive: false,
1670
1717
  multiHumanGroup: false,
1718
+ createdFromColdStart: isColdStart,
1719
+ coldStartSoloFallbackTurnActive: false,
1671
1720
  membershipFetch,
1672
1721
  pendingProviderError: null,
1673
1722
  destroyed: false,
@@ -1883,8 +1932,15 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1883
1932
 
1884
1933
  const subscribeTypingActivity = (session: AgentSession, live: LiveSession): (() => void) => {
1885
1934
  return session.subscribe((event) => {
1886
- if (event.type !== 'tool_execution_end') return
1887
- bumpTypingActivity(live)
1935
+ if (event.type === 'tool_execution_end') {
1936
+ bumpTypingActivity(live)
1937
+ return
1938
+ }
1939
+ if (event.type !== 'message_update') return
1940
+ const streamed = event.assistantMessageEvent.type
1941
+ if (streamed === 'text_delta' || streamed === 'thinking_delta') {
1942
+ bumpTypingActivity(live)
1943
+ }
1888
1944
  })
1889
1945
  }
1890
1946
 
@@ -2552,7 +2608,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2552
2608
 
2553
2609
  const membership = await membershipForEngagement(live)
2554
2610
 
2555
- live.multiHumanGroup = isMultiHumanGroup(event.isDm, countEffectiveHumans(live.participants, membership, now()))
2611
+ const effectiveHumans = countEffectiveHumans(live.participants, membership, now())
2612
+ live.multiHumanGroup = isMultiHumanGroup(event.isDm, effectiveHumans)
2556
2613
 
2557
2614
  const decision: EngagementDecision = decideEngagement({
2558
2615
  message: event,
@@ -2578,6 +2635,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2578
2635
 
2579
2636
  publishInbound(event, 'engage', live.sessionId)
2580
2637
 
2638
+ // Arm cold-start bare-empty recovery only for the exact incident shape: the
2639
+ // FIRST prompt (`turnSeq === 0`) of a freshly cold-started session that
2640
+ // engaged via the solo-human answer-everything fallback — a lone human, no
2641
+ // explicit mention/reply/DM, not a multi-human group. Recomputed on every
2642
+ // engage so it self-clears once the first turn advances `turnSeq`; explicit
2643
+ // address (mention/reply/DM) keeps the historical silent-on-empty path.
2644
+ live.coldStartSoloFallbackTurnActive =
2645
+ live.createdFromColdStart &&
2646
+ live.turnSeq === 0 &&
2647
+ effectiveHumans <= 1 &&
2648
+ !event.authorIsBot &&
2649
+ !event.isDm &&
2650
+ !event.isBotMention &&
2651
+ event.replyToBotMessageId === null &&
2652
+ !live.multiHumanGroup
2653
+
2581
2654
  const engageReaction = autoReactOnEngage(event)
2582
2655
 
2583
2656
  updateLoopGuard(live, event)
@@ -3315,11 +3388,21 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3315
3388
  const disengagedThisTurn = live.disengagedTurn !== null && live.disengagedTurn === live.turnSeq
3316
3389
  const adapterConfig = options.configForAdapter(msg.adapter)
3317
3390
  if (adapterConfig && !disengagedThisTurn) {
3318
- const targetIds = Array.from(
3319
- live.currentTurnAuthorIds.size > 0 ? live.currentTurnAuthorIds : live.lastTurnAuthorIds,
3320
- )
3321
- if (targetIds.length > 0) {
3322
- grantStickyForReplyTargets(stickyLedger, keyId, targetIds, adapterConfig.engagement, now())
3391
+ const targets = new Set(live.currentTurnAuthorIds.size > 0 ? live.currentTurnAuthorIds : live.lastTurnAuthorIds)
3392
+ // A user the agent addresses by @-mention is a reply target too: their
3393
+ // next message answers us without re-mentioning the bot. Granting them
3394
+ // sticky closes the gap where the agent asks "<@U123> can you confirm?"
3395
+ // and that user's plain reply was observed until they re-pinged.
3396
+ // Self-mentions (e.g. a quoted inbound) are excluded — we credit the
3397
+ // OTHERS we addressed, not ourselves.
3398
+ if (text !== undefined) {
3399
+ const selfId = resolveSelfIdentity(live.key)?.id
3400
+ for (const id of extractMentionedUserIds(msg.adapter, text)) {
3401
+ if (id !== selfId) targets.add(id)
3402
+ }
3403
+ }
3404
+ if (targets.size > 0) {
3405
+ grantStickyForReplyTargets(stickyLedger, keyId, Array.from(targets), adapterConfig.engagement, now())
3323
3406
  }
3324
3407
  }
3325
3408
  const turnCount = live.consecutiveSends.get(sendKey) ?? 0
@@ -3592,6 +3675,36 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3592
3675
  let assistantText = candidateText
3593
3676
 
3594
3677
  if (endsWithNoReplySignal(assistantText)) {
3678
+ // A BARE-EMPTY stop (no visible text, not an explicit NO_REPLY token) on
3679
+ // the armed cold-start solo-human fallback turn is the production "dropped
3680
+ // the owner's first question" shape — a model whiff on a direct one-on-one
3681
+ // question, not a deliberate decline. Give it the bounded empty-turn retry
3682
+ // with a dedicated nudge; on exhaustion post the visible fallback so the
3683
+ // human is never stranded on silence. Gated hard so deliberate silence
3684
+ // still stays silent: explicit NO_REPLY (non-empty trim), any turn that
3685
+ // already sent (successfulChannelSends moved), a queued fresh inbound (the
3686
+ // next drain answers it), and every turn outside the armed cold-start solo
3687
+ // path all fall through to the historical no_reply below.
3688
+ if (
3689
+ assistantText.trim() === '' &&
3690
+ source === 'leaf' &&
3691
+ live.coldStartSoloFallbackTurnActive &&
3692
+ live.currentTurnAuthorId !== null &&
3693
+ live.successfulChannelSends === successfulSendsBeforePrompt &&
3694
+ live.promptQueue.length === 0
3695
+ ) {
3696
+ if (live.emptyTurnRetries < MAX_EMPTY_TURN_RETRIES) {
3697
+ live.emptyTurnRetries++
3698
+ logger.warn(
3699
+ `[channels] ${live.keyId} empty_turn_retry attempt=${live.emptyTurnRetries}/${MAX_EMPTY_TURN_RETRIES} ` +
3700
+ `cause=cold_start_solo_bare_empty`,
3701
+ )
3702
+ live.pendingSystemReminders.push(COLD_START_REPLY_NUDGE)
3703
+ return
3704
+ }
3705
+ await postEmptyTurnFallback('cold_start_solo_bare_empty_retries_exhausted')
3706
+ return
3707
+ }
3595
3708
  const leakedReasoning = !isNoReplySignal(assistantText)
3596
3709
  logger.info(`[channels] ${live.keyId} no_reply${leakedReasoning ? ' (with_leaked_reasoning)' : ''}`)
3597
3710
  return
@@ -4462,7 +4575,7 @@ function composeTurnPrompt(
4462
4575
  if (observed.length > 0) {
4463
4576
  parts.push('## Recent context (not addressed to you, for awareness only)')
4464
4577
  for (const o of observed) {
4465
- parts.push(formatInboundPromptLines(o, adapter))
4578
+ parts.push(formatInboundPromptLines(o, adapter, OBSERVED_MESSAGE_MAX_CHARS))
4466
4579
  }
4467
4580
  parts.push('')
4468
4581
  }
@@ -4521,10 +4634,22 @@ function formatAuthorLine(
4521
4634
  authorName: string,
4522
4635
  authorIsBot: boolean,
4523
4636
  text: string,
4637
+ maxChars?: number,
4524
4638
  ): string {
4525
4639
  const tag = authorIsBot ? ' [bot]' : ''
4526
4640
  const stamp = ts > 0 ? `[${new Date(ts).toISOString()}] ` : ''
4527
- return `${stamp}${formatAuthorReference(adapter, authorId, authorName)} (${authorName})${tag}: ${text}`
4641
+ return `${stamp}${formatAuthorReference(adapter, authorId, authorName)} (${authorName})${tag}: ${capObservedText(text, maxChars)}`
4642
+ }
4643
+
4644
+ // Cap by whole code points so truncation never splits a surrogate pair (emoji,
4645
+ // astral-plane chars) into a dangling half. `text.length` (UTF-16 code units) is
4646
+ // a cheap upper bound on the code-point count, so a string already within the
4647
+ // cap skips the array build.
4648
+ function capObservedText(text: string, maxChars: number | undefined): string {
4649
+ if (maxChars === undefined || text.length <= maxChars) return text
4650
+ const points = Array.from(text)
4651
+ if (points.length <= maxChars) return text
4652
+ return `${points.slice(0, maxChars).join('')} […truncated]`
4528
4653
  }
4529
4654
 
4530
4655
  function formatInboundPromptLines(
@@ -4537,10 +4662,19 @@ function formatInboundPromptLines(
4537
4662
  referenceContext?: InboundReferenceContext
4538
4663
  },
4539
4664
  adapter: AdapterId,
4665
+ maxTextChars?: number,
4540
4666
  ): string {
4541
4667
  const lines = inbound.referenceContext?.sources.map(renderQuoteAnchor) ?? []
4542
4668
  lines.push(
4543
- formatAuthorLine(inbound.ts, adapter, inbound.authorId, inbound.authorName, inbound.authorIsBot, inbound.text),
4669
+ formatAuthorLine(
4670
+ inbound.ts,
4671
+ adapter,
4672
+ inbound.authorId,
4673
+ inbound.authorName,
4674
+ inbound.authorIsBot,
4675
+ inbound.text,
4676
+ maxTextChars,
4677
+ ),
4544
4678
  )
4545
4679
  return lines.join('\n')
4546
4680
  }
package/src/cli/dreams.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
3
  import { type DreamEntry, renderListRow, runDreams, type ViewAction } from '@/dreams'
4
- import { findAgentDir } from '@/init'
5
4
 
6
5
  import { createEscController } from './inspect-controller'
6
+ import { requireAgentDir } from './require-agent-dir'
7
7
  import { c, cancel, errorLine, isCancel, prepareStdinForClack } from './ui'
8
8
 
9
9
  const ESC_DEBOUNCE_MS = 50
@@ -31,7 +31,7 @@ export const dreamsCommand = defineCommand({
31
31
  },
32
32
  },
33
33
  async run({ args }) {
34
- const cwd = findAgentDir(process.cwd()) ?? process.cwd()
34
+ const cwd = requireAgentDir()
35
35
  const color = useColor()
36
36
  const limit = parseLimit(args.limit)
37
37
  const interactive = isInteractive() && args.json !== true
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(errorLine(`TypeClaw has already hatched in ${cwd}.`))
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 (isDirectoryNonEmpty(cwd)) {
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 delete `secrets.json` if you want a clean restart.',
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
- s.stop(event.result.ok ? 'Dependencies installed.' : `Skipped bun install: ${event.result.reason}`)
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.stop(`Skipped Dockerfile: ${event.result.reason}`)
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.stop(`Skipped git init: ${event.result.reason}`)
1800
+ s.error(`git init failed — continuing without a repo: ${event.result.reason}`)
1767
1801
  }
1768
1802
  break
1769
1803
  }
@@ -1,7 +1,6 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
3
  import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
4
- import { findAgentDir } from '@/init'
5
4
  import {
6
5
  fetchLiveSessions,
7
6
  listViewerItems,
@@ -20,6 +19,7 @@ import {
20
19
  import { originLabel, shortSessionId } from '@/inspect/label'
21
20
 
22
21
  import { createTailScope } from './inspect-controller'
22
+ import { requireAgentDir } from './require-agent-dir'
23
23
  import { cancel, c, errorLine, isCancel, prepareStdinForClack } from './ui'
24
24
 
25
25
  const ESC_DEBOUNCE_MS = 50
@@ -51,7 +51,7 @@ export const inspectCommand = defineCommand({
51
51
  },
52
52
  },
53
53
  async run({ args }) {
54
- const cwd = findAgentDir(process.cwd()) ?? process.cwd()
54
+ const cwd = requireAgentDir()
55
55
  const color = useColor()
56
56
  const sessionArg = typeof args.session === 'string' ? args.session : undefined
57
57
  const filterArg = typeof args.filter === 'string' ? args.filter : undefined
package/src/cli/logs.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
3
  import { logs, parseTailValue } from '@/container'
4
- import { findAgentDir } from '@/init'
5
4
 
6
5
  import { runInspectViewer } from './inspect'
6
+ import { requireAgentDir } from './require-agent-dir'
7
7
  import { c, errorLine } from './ui'
8
8
 
9
9
  export const logsCommand = defineCommand({
@@ -30,7 +30,7 @@ export const logsCommand = defineCommand({
30
30
  },
31
31
  },
32
32
  async run({ args }) {
33
- const cwd = findAgentDir(process.cwd()) ?? process.cwd()
33
+ const cwd = requireAgentDir()
34
34
 
35
35
  let tail: string | undefined
36
36
  if (args.tail !== undefined) {
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
- const platform = process.platform
112
- if (platform === 'darwin') {
113
+ if (isMacOS()) {
113
114
  await execFileAsync('open', [filePath])
114
115
  return
115
116
  }
116
- if (platform === 'win32') {
117
+ if (isWindows()) {
117
118
  await execFileAsync('cmd', ['/c', 'start', '', filePath])
118
119
  return
119
120
  }
@@ -0,0 +1,31 @@
1
+ import { findAgentDir, isInitialized } from '@/init'
2
+
3
+ import { errorLine } from './ui'
4
+
5
+ export type RequiredAgentDir = { ok: true; cwd: string } | { ok: false; message: string }
6
+
7
+ const NOT_AN_AGENT_FOLDER = 'TypeClaw config file not found. Run `typeclaw init` first, or cd into an agent folder.'
8
+
9
+ // Operational host-stage commands (inspect, tui, logs, stop, shell, dreams) act
10
+ // on a specific agent's container or on-disk state, so they must run from inside
11
+ // an agent folder. The `findAgentDir(...) ?? startDir` fallback every caller used
12
+ // silently degraded these commands into an agent-less view — e.g. `inspect` would
13
+ // warn "container not running" and then offer only the container-logs row — instead
14
+ // of failing. Resolving to a fail result keeps the existence check explicit.
15
+ //
16
+ // Diagnostic commands (status, doctor, role list) deliberately tolerate a missing
17
+ // agent folder and must NOT use this gate.
18
+ export function resolveRequiredAgentDir(startDir: string): RequiredAgentDir {
19
+ const cwd = findAgentDir(startDir) ?? startDir
20
+ if (!isInitialized(cwd)) return { ok: false, message: NOT_AN_AGENT_FOLDER }
21
+ return { ok: true, cwd }
22
+ }
23
+
24
+ export function requireAgentDir(startDir: string = process.cwd()): string {
25
+ const result = resolveRequiredAgentDir(startDir)
26
+ if (!result.ok) {
27
+ console.error(errorLine(result.message))
28
+ process.exit(1)
29
+ }
30
+ return result.cwd
31
+ }
package/src/cli/shell.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
3
  import { shell } from '@/container'
4
- import { findAgentDir } from '@/init'
5
4
 
5
+ import { requireAgentDir } from './require-agent-dir'
6
6
  import { c, errorLine } from './ui'
7
7
 
8
8
  export const shellCommand = defineCommand({
@@ -18,7 +18,7 @@ export const shellCommand = defineCommand({
18
18
  },
19
19
  },
20
20
  async run({ args }) {
21
- const cwd = findAgentDir(process.cwd()) ?? process.cwd()
21
+ const cwd = requireAgentDir()
22
22
 
23
23
  console.log(c.cyan(`Attaching ${args.shell} inside the container...`))
24
24
 
package/src/cli/stop.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
3
  import { stop } from '@/container'
4
- import { findAgentDir } from '@/init'
5
4
 
5
+ import { requireAgentDir } from './require-agent-dir'
6
6
  import { c, spinner } from './ui'
7
7
 
8
8
  export const stopCommand = defineCommand({
@@ -11,7 +11,7 @@ export const stopCommand = defineCommand({
11
11
  description: 'stop the agent container (host stage)',
12
12
  },
13
13
  async run() {
14
- const cwd = findAgentDir(process.cwd()) ?? process.cwd()
14
+ const cwd = requireAgentDir()
15
15
 
16
16
  const s = spinner()
17
17
  s.start('Stopping container...')
package/src/cli/tui.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
3
  import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
4
- import { findAgentDir } from '@/init'
5
4
  import { CLI_VERSION } from '@/init/cli-version'
6
5
  import { runTuiViewer } from '@/inspect'
7
6
  import { formatVersionMismatchWarning } from '@/tui'
8
7
 
9
8
  import { runInspectViewer } from './inspect'
9
+ import { requireAgentDir } from './require-agent-dir'
10
10
  import { errorLine } from './ui'
11
11
 
12
12
  export const tui = defineCommand({
@@ -27,9 +27,22 @@ export const tui = defineCommand({
27
27
  },
28
28
  },
29
29
  async run({ args }) {
30
- const cwd = findAgentDir(process.cwd()) ?? process.cwd()
31
- const resolveUrl: () => Promise<string> =
32
- args.url !== undefined ? async () => args.url as string : () => defaultUrl(cwd)
30
+ // An explicit --url targets an agent over the wire and needs no local agent
31
+ // folder only the default-URL discovery and the esc-detach picker read
32
+ // local state. Require an agent folder only when --url is absent, mirroring
33
+ // how reload/cron/role claim gate their default-target path, not the whole
34
+ // command.
35
+ const explicitUrl = typeof args.url === 'string' ? args.url : undefined
36
+ let cwd: string | undefined
37
+ let resolveUrl: () => Promise<string>
38
+ if (explicitUrl === undefined) {
39
+ const agentDir = requireAgentDir()
40
+ cwd = agentDir
41
+ resolveUrl = () => defaultUrl(agentDir)
42
+ } else {
43
+ cwd = undefined
44
+ resolveUrl = async () => explicitUrl
45
+ }
33
46
 
34
47
  const result = await runTuiViewer({
35
48
  resolveUrl,
@@ -45,8 +58,9 @@ export const tui = defineCommand({
45
58
  // can pick another session or the container logs — `tui` is just a deep-link
46
59
  // into the session viewer, pre-opened on the live session. allowWritable
47
60
  // is false because detaching ended the live session, so no row may be
48
- // offered as a writable "live TUI" anymore.
49
- if (result.ok && result.escToPicker === true) {
61
+ // offered as a writable "live TUI" anymore. A --url-only run has no local
62
+ // agent folder to browse, so it exits instead of opening the local picker.
63
+ if (result.ok && result.escToPicker === true && cwd !== undefined) {
50
64
  const viewerExit = await runInspectViewer({ cwd, allowWritable: false })
51
65
  process.exit(viewerExit)
52
66
  return
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. So before every
18
- // picker: clear any stale raw mode for a clean baseline, then resume the stream.
19
- // Never pause() here a previously-paused process.stdin does not reliably
20
- // re-flow under Bun, which is the same failure this resume() is fixing.
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 */
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto'
1
2
  import { basename, resolve } from 'node:path'
2
3
 
3
4
  export type DockerExecResult = { exitCode: number; stdout: string; stderr: string }
@@ -249,8 +250,25 @@ export function imageTagFromCwd(cwd: string): string {
249
250
  }
250
251
 
251
252
  // Docker container names must match [a-zA-Z0-9][a-zA-Z0-9_.-]*.
253
+ //
254
+ // Non-ASCII names (Korean/CJK/Cyrillic/accented Latin — the common Windows case
255
+ // where the profile folder is a localized display name, e.g. C:\Users\사용자\봇)
256
+ // have every out-of-charset character collapsed to a dash, so distinct folders
257
+ // reduce to the SAME string: '봇' and '집' both → 'tc--'. The container name keys
258
+ // hostd registration and the secrets key path, so that collision is silent
259
+ // host-side state clobbering, not cosmetics. For any name carrying a non-ASCII
260
+ // char we append a deterministic hash of the original (cf. makeUntitledSlug in
261
+ // the memory plugin) to keep distinct folders distinct; surviving ASCII stays a
262
+ // readable prefix. ASCII-only names take the original branch — never renamed.
252
263
  function sanitizeContainerName(name: string): string {
253
264
  const cleaned = name.replace(/[^a-zA-Z0-9_.-]/g, '-')
265
+ if (/[^\u0000-\u007f]/.test(name)) {
266
+ const hash = createHash('sha256').update(name).digest('hex').slice(0, 8)
267
+ const remnant = cleaned.replace(/-+/g, '-').replace(/^-+|-+$/g, '')
268
+ if (remnant === '') return `tc-${hash}`
269
+ const base = /^[a-zA-Z0-9]/.test(remnant) ? remnant : `tc-${remnant}`
270
+ return `${base}-${hash}`
271
+ }
254
272
  if (cleaned === '' || !/^[a-zA-Z0-9]/.test(cleaned)) {
255
273
  return `tc-${cleaned || 'agent'}`
256
274
  }
@@ -671,7 +671,7 @@ export async function planStart({
671
671
  // so mounting an empty cache would only invite a confusing local_files_only
672
672
  // miss if something inside the container reached for the model anyway.
673
673
  if (agentUsesVector(cwd)) {
674
- runArgs.push('-v', `${homeRoot()}/models:/opt/models:ro`)
674
+ runArgs.push('-v', `${join(homeRoot(), 'models')}:/opt/models:ro`)
675
675
  runArgs.push('-e', 'TYPECLAW_MODEL_CACHE=/opt/models')
676
676
  }
677
677