typeclaw 0.37.4 → 0.37.6

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 (51) hide show
  1. package/package.json +1 -1
  2. package/src/agent/doctor.ts +6 -1
  3. package/src/agent/plugin-tools.ts +23 -1
  4. package/src/agent/subagents.ts +146 -14
  5. package/src/agent/todo/scope.ts +4 -2
  6. package/src/agent/tools/channel-reply.ts +7 -9
  7. package/src/bundled-plugins/doc-render/index.ts +10 -0
  8. package/src/bundled-plugins/doc-render/skills/typeclaw-render-pdf/SKILL.md +171 -165
  9. package/src/bundled-plugins/doc-render/templates/lib.typ +339 -0
  10. package/src/bundled-plugins/github-cli-auth/gh-command.ts +95 -11
  11. package/src/bundled-plugins/github-cli-auth/git-command.ts +11 -0
  12. package/src/bundled-plugins/github-cli-auth/index.ts +68 -7
  13. package/src/bundled-plugins/memory/index.ts +9 -6
  14. package/src/bundled-plugins/memory/load-memory.ts +16 -2
  15. package/src/bundled-plugins/memory/slug.ts +19 -0
  16. package/src/bundled-plugins/security/policies/private-surface-read.ts +4 -1
  17. package/src/channels/adapters/github/inbound.ts +68 -43
  18. package/src/channels/adapters/github/index.ts +57 -9
  19. package/src/channels/adapters/github/recover-failed-deliveries.ts +270 -0
  20. package/src/channels/adapters/kakaotalk.ts +5 -1
  21. package/src/channels/adapters/mention-hints.ts +17 -0
  22. package/src/channels/manager.ts +77 -1
  23. package/src/channels/router.ts +181 -12
  24. package/src/cli/compose.ts +11 -2
  25. package/src/cli/dreams.ts +2 -2
  26. package/src/cli/inspect.ts +2 -2
  27. package/src/cli/logs.ts +2 -2
  28. package/src/cli/mount.ts +5 -5
  29. package/src/cli/require-agent-dir.ts +31 -0
  30. package/src/cli/restart.ts +2 -1
  31. package/src/cli/shell.ts +2 -2
  32. package/src/cli/start.ts +2 -1
  33. package/src/cli/stop.ts +2 -2
  34. package/src/cli/tui.ts +20 -6
  35. package/src/cli/ui.ts +13 -0
  36. package/src/compose/restart.ts +1 -1
  37. package/src/compose/start.ts +4 -2
  38. package/src/config/config.ts +200 -9
  39. package/src/container/shared.ts +18 -0
  40. package/src/container/start.ts +1 -1
  41. package/src/cron/consumer.ts +3 -3
  42. package/src/hostd/client.ts +48 -52
  43. package/src/hostd/daemon.ts +82 -39
  44. package/src/hostd/paths.ts +22 -2
  45. package/src/hostd/spawn.ts +7 -0
  46. package/src/init/dockerfile.ts +11 -8
  47. package/src/init/kakaotalk-auth.ts +2 -2
  48. package/src/init/packagejson.ts +2 -2
  49. package/src/plugin/loader.ts +7 -4
  50. package/src/sandbox/session-tmp.ts +6 -1
  51. package/src/secrets/export-claude-credentials-file.ts +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 {
@@ -111,13 +112,18 @@ export const TYPING_HEARTBEAT_MS = 8000
111
112
  // turns a temporary status into a permanent-looking artifact.
112
113
  //
113
114
  // The cap is measured from `live.typingStartedAt`, which is refreshed by
114
- // two signals of life (see `bumpTypingActivity`):
115
+ // these signals of life (see `bumpTypingActivity`):
115
116
  // 1. Each new `drain()` iteration (a new turn is starting).
116
117
  // 2. Each `tool_execution_end` from the agent session (a tool just
117
118
  // completed — the prompt is progressing, not stuck).
118
- // A 2-minute bash command that emits no intermediate events still trips
119
- // the cap, but a chatty agent running long tools stays under it
120
- // 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.
121
127
  export const MAX_TYPING_HEARTBEAT_MS = 2 * 60 * 1000
122
128
 
123
129
  // Idle GC: a LiveSession whose `lastInboundAt` is older than
@@ -260,6 +266,28 @@ export const EMPTY_TURN_RETRY_NUDGE = [
260
266
  // drop so the human is never left staring at dead air after a degenerate turn.
261
267
  export const EMPTY_TURN_FALLBACK_TEXT =
262
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')
263
291
  // At most one continuation nudge per logical turn. Stricter than the empty-turn
264
292
  // retry budget (2) because the turn ALREADY delivered a user-facing reply — this
265
293
  // is a one-shot correction offer, not recovery from no output.
@@ -579,6 +607,11 @@ type LiveSession = {
579
607
  typingStartedAt: number
580
608
  typingTimedOut: boolean
581
609
  typingStopPromise: Promise<void> | null
610
+ // True only while `live.session.prompt()` is actively running. Gates the
611
+ // deferred typing revival: a revival queued behind an in-flight cap-trip
612
+ // 'stop' must NOT re-arm the heartbeat once the prompt has finished, or it
613
+ // leaks a timer that fires past the turn's end.
614
+ promptInFlight: boolean
582
615
  lastInboundAt: number
583
616
  // Transcript-file size (bytes) captured immediately after cold-start, before
584
617
  // any user turn — a proxy for the fixed base-context rebuild cost (rendered
@@ -772,6 +805,20 @@ type LiveSession = {
772
805
  // decision used, so the prompt nudge and sticky suppression agree on
773
806
  // "is this a multi-human group". Read by composeTurnPrompt().
774
807
  multiHumanGroup: boolean
808
+ // True when this live session was born from a cold-start (no persisted
809
+ // session existed — first contact or a stale-rollover after long idle), as
810
+ // opposed to rehydrating an existing session. Combined with `turnSeq === 0`
811
+ // it pinpoints the very first prompt of a freshly woken session.
812
+ createdFromColdStart: boolean
813
+ // Set in route() when the FIRST turn of a cold-start session engages via the
814
+ // solo-human "answer everything" fallback (not an explicit mention/reply/DM,
815
+ // not a multi-human group). Read by validateChannelTurn: a BARE-EMPTY stop on
816
+ // such a turn is a model whiff on a direct one-on-one question, not deliberate
817
+ // silence, so it earns an empty-turn retry instead of a silent no_reply.
818
+ // Recomputed on every engage, so it self-clears once turnSeq leaves the first
819
+ // turn; explicit NO_REPLY / skip_response and any turn that already sent stay
820
+ // on the historical silent path.
821
+ coldStartSoloFallbackTurnActive: boolean
775
822
  membershipFetch: Promise<MembershipCount | null> | null
776
823
  // Provider soft-error (`stopReason: 'error'`) captured during the current
777
824
  // turn, deferred to turn-end. Upstream surfaces transient errors (e.g.
@@ -1631,6 +1678,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1631
1678
  typingStartedAt: 0,
1632
1679
  typingTimedOut: false,
1633
1680
  typingStopPromise: null,
1681
+ promptInFlight: false,
1634
1682
  lastInboundAt: now(),
1635
1683
  baseContextBytes: 0,
1636
1684
  firstUnprocessedAt: 0,
@@ -1673,6 +1721,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1673
1721
  consecutiveEngagedPeerBotTurns: 0,
1674
1722
  loopGuardActive: false,
1675
1723
  multiHumanGroup: false,
1724
+ createdFromColdStart: isColdStart,
1725
+ coldStartSoloFallbackTurnActive: false,
1676
1726
  membershipFetch,
1677
1727
  pendingProviderError: null,
1678
1728
  destroyed: false,
@@ -1886,10 +1936,70 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1886
1936
  live.typingStartedAt = now()
1887
1937
  }
1888
1938
 
1939
+ // Re-arm a heartbeat that the silence cap already stopped. PR #930 made
1940
+ // streamed deltas refresh the clock, but only via `bumpTypingActivity`,
1941
+ // which no-ops once `typingTimer` is null. So a turn that goes silent for
1942
+ // >MAX_TYPING_HEARTBEAT_MS (a long single tool call, a slow provider, an
1943
+ // extended-thinking phase that emits no deltas) trips the cap, and the
1944
+ // delta/tool signals that arrive afterwards can no longer revive it —
1945
+ // `startTypingHeartbeat` short-circuits on `typingTimedOut`, which only
1946
+ // resets at the next drain() iteration (i.e. never, within one long turn).
1947
+ // Reviving here lets demonstrable progress after a timeout bring the
1948
+ // indicator back. The revival is gated on streamed activity ONLY, never on
1949
+ // a new inbound (a later inbound during the same in-flight turn must stay
1950
+ // silenced — see the matching router test).
1951
+ //
1952
+ // On Slack this is the visible bug: its `'stop'` phase sends a permanent
1953
+ // empty-string `setStatus` clear that does not auto-expire, so a tripped
1954
+ // indicator stays gone. Discord/Telegram mask the same defect because their
1955
+ // native indicators auto-expire and self-heal on the next tick.
1956
+ const reviveTypingActivity = (live: LiveSession): void => {
1957
+ if (live.destroyed) return
1958
+ if (!live.typingTimedOut) {
1959
+ bumpTypingActivity(live)
1960
+ return
1961
+ }
1962
+ // A `'stop'` clear may still be in flight. Starting a new heartbeat now
1963
+ // would short-circuit on the non-null `typingStopPromise` (losing the
1964
+ // revival), and firing a fresh `'tick'` before Slack's empty-string clear
1965
+ // lands would let the clear wipe the just-revived status. Defer until the
1966
+ // stop settles so the new `'tick'` is ordered strictly after the clear.
1967
+ const stopPromise = live.typingStopPromise
1968
+ if (stopPromise) {
1969
+ void stopPromise.catch(() => undefined).then(() => restartTypingAfterTimeout(live))
1970
+ return
1971
+ }
1972
+ restartTypingAfterTimeout(live)
1973
+ }
1974
+
1975
+ const restartTypingAfterTimeout = (live: LiveSession): void => {
1976
+ // Re-checked after the awaited stop:
1977
+ // - `destroyed`: the session may have been torn down.
1978
+ // - `!typingTimedOut`: an earlier queued revival already cleared the flag
1979
+ // and re-armed (so this one is a no-op — no double interval/tick).
1980
+ // - `!promptInFlight`: the prompt finished while the cap-trip 'stop' was
1981
+ // still in flight. A revival queued by a late delta would otherwise
1982
+ // re-arm a heartbeat after generation ended — a timer nothing stops
1983
+ // (drain()'s turn-end stop already ran with `typingTimer === null`).
1984
+ // `draining` is too coarse: it stays true through the turn-end hook tail
1985
+ // (turn-end/idle/todos) after `prompt()` returns, so a revival running
1986
+ // in that window would still leak. Gate on active generation instead.
1987
+ if (live.destroyed || !live.promptInFlight || !live.typingTimedOut) return
1988
+ live.typingTimedOut = false
1989
+ startTypingHeartbeat(live)
1990
+ }
1991
+
1889
1992
  const subscribeTypingActivity = (session: AgentSession, live: LiveSession): (() => void) => {
1890
1993
  return session.subscribe((event) => {
1891
- if (event.type !== 'tool_execution_end') return
1892
- bumpTypingActivity(live)
1994
+ if (event.type === 'tool_execution_end') {
1995
+ reviveTypingActivity(live)
1996
+ return
1997
+ }
1998
+ if (event.type !== 'message_update') return
1999
+ const streamed = event.assistantMessageEvent.type
2000
+ if (streamed === 'text_delta' || streamed === 'thinking_delta') {
2001
+ reviveTypingActivity(live)
2002
+ }
1893
2003
  })
1894
2004
  }
1895
2005
 
@@ -2287,6 +2397,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2287
2397
  const isRealUserTurn = batch.length > 0
2288
2398
  const retrievalContext = await fireSessionTurnStart(live, composeRetrievalQuery(batch))
2289
2399
  const promptText = retrievalContext.results.length > 0 ? `${text}\n\n${retrievalContext.results}` : text
2400
+ live.promptInFlight = true
2290
2401
  try {
2291
2402
  await live.session.prompt(promptText)
2292
2403
  await validateChannelTurn(live, successfulSendsBeforePrompt)
@@ -2298,6 +2409,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2298
2409
  live.lastSentText.clear()
2299
2410
  live.lastSendLeafId = null
2300
2411
  } finally {
2412
+ live.promptInFlight = false
2301
2413
  const sentReplyThisTurn = live.successfulChannelSends > successfulSendsBeforePrompt
2302
2414
  if (sentReplyThisTurn) dropEngageReactionsAfterReply(live, engageAddPromises)
2303
2415
  const emptyTurnRetryQueued = live.emptyTurnRetries > emptyTurnRetriesBeforePrompt
@@ -2557,7 +2669,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2557
2669
 
2558
2670
  const membership = await membershipForEngagement(live)
2559
2671
 
2560
- live.multiHumanGroup = isMultiHumanGroup(event.isDm, countEffectiveHumans(live.participants, membership, now()))
2672
+ const effectiveHumans = countEffectiveHumans(live.participants, membership, now())
2673
+ live.multiHumanGroup = isMultiHumanGroup(event.isDm, effectiveHumans)
2561
2674
 
2562
2675
  const decision: EngagementDecision = decideEngagement({
2563
2676
  message: event,
@@ -2583,6 +2696,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2583
2696
 
2584
2697
  publishInbound(event, 'engage', live.sessionId)
2585
2698
 
2699
+ // Arm cold-start bare-empty recovery only for the exact incident shape: the
2700
+ // FIRST prompt (`turnSeq === 0`) of a freshly cold-started session that
2701
+ // engaged via the solo-human answer-everything fallback — a lone human, no
2702
+ // explicit mention/reply/DM, not a multi-human group. Recomputed on every
2703
+ // engage so it self-clears once the first turn advances `turnSeq`; explicit
2704
+ // address (mention/reply/DM) keeps the historical silent-on-empty path.
2705
+ live.coldStartSoloFallbackTurnActive =
2706
+ live.createdFromColdStart &&
2707
+ live.turnSeq === 0 &&
2708
+ effectiveHumans <= 1 &&
2709
+ !event.authorIsBot &&
2710
+ !event.isDm &&
2711
+ !event.isBotMention &&
2712
+ event.replyToBotMessageId === null &&
2713
+ !live.multiHumanGroup
2714
+
2586
2715
  const engageReaction = autoReactOnEngage(event)
2587
2716
 
2588
2717
  updateLoopGuard(live, event)
@@ -3320,11 +3449,21 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3320
3449
  const disengagedThisTurn = live.disengagedTurn !== null && live.disengagedTurn === live.turnSeq
3321
3450
  const adapterConfig = options.configForAdapter(msg.adapter)
3322
3451
  if (adapterConfig && !disengagedThisTurn) {
3323
- const targetIds = Array.from(
3324
- live.currentTurnAuthorIds.size > 0 ? live.currentTurnAuthorIds : live.lastTurnAuthorIds,
3325
- )
3326
- if (targetIds.length > 0) {
3327
- grantStickyForReplyTargets(stickyLedger, keyId, targetIds, adapterConfig.engagement, now())
3452
+ const targets = new Set(live.currentTurnAuthorIds.size > 0 ? live.currentTurnAuthorIds : live.lastTurnAuthorIds)
3453
+ // A user the agent addresses by @-mention is a reply target too: their
3454
+ // next message answers us without re-mentioning the bot. Granting them
3455
+ // sticky closes the gap where the agent asks "<@U123> can you confirm?"
3456
+ // and that user's plain reply was observed until they re-pinged.
3457
+ // Self-mentions (e.g. a quoted inbound) are excluded — we credit the
3458
+ // OTHERS we addressed, not ourselves.
3459
+ if (text !== undefined) {
3460
+ const selfId = resolveSelfIdentity(live.key)?.id
3461
+ for (const id of extractMentionedUserIds(msg.adapter, text)) {
3462
+ if (id !== selfId) targets.add(id)
3463
+ }
3464
+ }
3465
+ if (targets.size > 0) {
3466
+ grantStickyForReplyTargets(stickyLedger, keyId, Array.from(targets), adapterConfig.engagement, now())
3328
3467
  }
3329
3468
  }
3330
3469
  const turnCount = live.consecutiveSends.get(sendKey) ?? 0
@@ -3597,6 +3736,36 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3597
3736
  let assistantText = candidateText
3598
3737
 
3599
3738
  if (endsWithNoReplySignal(assistantText)) {
3739
+ // A BARE-EMPTY stop (no visible text, not an explicit NO_REPLY token) on
3740
+ // the armed cold-start solo-human fallback turn is the production "dropped
3741
+ // the owner's first question" shape — a model whiff on a direct one-on-one
3742
+ // question, not a deliberate decline. Give it the bounded empty-turn retry
3743
+ // with a dedicated nudge; on exhaustion post the visible fallback so the
3744
+ // human is never stranded on silence. Gated hard so deliberate silence
3745
+ // still stays silent: explicit NO_REPLY (non-empty trim), any turn that
3746
+ // already sent (successfulChannelSends moved), a queued fresh inbound (the
3747
+ // next drain answers it), and every turn outside the armed cold-start solo
3748
+ // path all fall through to the historical no_reply below.
3749
+ if (
3750
+ assistantText.trim() === '' &&
3751
+ source === 'leaf' &&
3752
+ live.coldStartSoloFallbackTurnActive &&
3753
+ live.currentTurnAuthorId !== null &&
3754
+ live.successfulChannelSends === successfulSendsBeforePrompt &&
3755
+ live.promptQueue.length === 0
3756
+ ) {
3757
+ if (live.emptyTurnRetries < MAX_EMPTY_TURN_RETRIES) {
3758
+ live.emptyTurnRetries++
3759
+ logger.warn(
3760
+ `[channels] ${live.keyId} empty_turn_retry attempt=${live.emptyTurnRetries}/${MAX_EMPTY_TURN_RETRIES} ` +
3761
+ `cause=cold_start_solo_bare_empty`,
3762
+ )
3763
+ live.pendingSystemReminders.push(COLD_START_REPLY_NUDGE)
3764
+ return
3765
+ }
3766
+ await postEmptyTurnFallback('cold_start_solo_bare_empty_retries_exhausted')
3767
+ return
3768
+ }
3600
3769
  const leakedReasoning = !isNoReplySignal(assistantText)
3601
3770
  logger.info(`[channels] ${live.keyId} no_reply${leakedReasoning ? ' (with_leaked_reasoning)' : ''}`)
3602
3771
  return
@@ -325,7 +325,8 @@ function makeBoard(header: string): Board {
325
325
  function formatStartDone<T extends { alreadyRunning?: boolean; hostPort: number }>(result: AgentResult<T>): string {
326
326
  if (!result.ok) return `${c.red('✖')} ${c.red('failed:')} ${result.reason}`
327
327
  const verb = result.data.alreadyRunning ? 'already running' : 'started'
328
- return `${c.green('✔')} ${verb} on host port ${c.cyan(String(result.data.hostPort))}`
328
+ const head = `${c.green('✔')} ${verb} on host port ${c.cyan(String(result.data.hostPort))}`
329
+ return appendWarnings(head, result.warnings)
329
330
  }
330
331
 
331
332
  function formatStopDone<T extends { running: boolean }>(result: AgentResult<T>): string {
@@ -336,7 +337,15 @@ function formatStopDone<T extends { running: boolean }>(result: AgentResult<T>):
336
337
 
337
338
  function formatRestartDone<T extends { start: { hostPort: number } }>(result: AgentResult<T>): string {
338
339
  if (!result.ok) return `${c.red('✖')} ${c.red('failed:')} ${result.reason}`
339
- return `${c.green('✔')} restarted on host port ${c.cyan(String(result.data.start.hostPort))}`
340
+ const head = `${c.green('✔')} restarted on host port ${c.cyan(String(result.data.start.hostPort))}`
341
+ return appendWarnings(head, result.warnings)
342
+ }
343
+
344
+ // Surface non-fatal validateConfig warnings under the per-agent compose status
345
+ // line so compose start/restart don't silently drop what `typeclaw start` prints.
346
+ function appendWarnings(head: string, warnings: string[] | undefined): string {
347
+ if (warnings === undefined || warnings.length === 0) return head
348
+ return [head, ...warnings.map((w) => ` ${c.yellow('⚠')} ${w}`)].join('\n')
340
349
  }
341
350
 
342
351
  function emitComposeDoctor(report: ComposeDoctorReport, opts: { verbose: boolean; json: boolean }): void {
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
@@ -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/mount.ts CHANGED
@@ -8,7 +8,7 @@ import { c, errorLine, successLine } from './ui'
8
8
  const listSub = defineCommand({
9
9
  meta: {
10
10
  name: 'list',
11
- description: 'list host directories mounted into the agent container',
11
+ description: 'list host files and directories mounted into the agent container',
12
12
  },
13
13
  args: {
14
14
  json: {
@@ -31,7 +31,7 @@ const listSub = defineCommand({
31
31
  const addSub = defineCommand({
32
32
  meta: {
33
33
  name: 'add',
34
- description: 'add a host directory mount to typeclaw.json',
34
+ description: 'add a host file or directory mount to typeclaw.json',
35
35
  },
36
36
  args: {
37
37
  name: {
@@ -41,7 +41,7 @@ const addSub = defineCommand({
41
41
  },
42
42
  path: {
43
43
  type: 'positional',
44
- description: 'host directory path to expose inside the container',
44
+ description: 'host file or directory path to expose inside the container',
45
45
  required: true,
46
46
  },
47
47
  'read-only': {
@@ -74,7 +74,7 @@ const addSub = defineCommand({
74
74
  const removeSub = defineCommand({
75
75
  meta: {
76
76
  name: 'remove',
77
- description: 'remove a host directory mount from typeclaw.json',
77
+ description: 'remove a host mount from typeclaw.json',
78
78
  },
79
79
  args: {
80
80
  name: {
@@ -98,7 +98,7 @@ const removeSub = defineCommand({
98
98
  export const mountCommand = defineCommand({
99
99
  meta: {
100
100
  name: 'mount',
101
- description: 'manage host directories mounted into the agent container',
101
+ description: 'manage host files and directories mounted into the agent container',
102
102
  },
103
103
  subCommands: {
104
104
  list: listSub,
@@ -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
+ }
@@ -6,7 +6,7 @@ import { start, stop } from '@/container'
6
6
  import { findAgentDir, isInitialized } from '@/init'
7
7
 
8
8
  import { guardIncompleteInit } from './incomplete-init'
9
- import { c, errorLine, renderStartSuccess, spinner } from './ui'
9
+ import { c, errorLine, renderStartSuccess, reportConfigWarnings, spinner } from './ui'
10
10
 
11
11
  export const restartCommand = defineCommand({
12
12
  meta: {
@@ -61,6 +61,7 @@ export const restartCommand = defineCommand({
61
61
  console.error(errorLine(validated.reason))
62
62
  process.exit(1)
63
63
  }
64
+ reportConfigWarnings(validated.warnings)
64
65
 
65
66
  const stopSpin = spinner()
66
67
  stopSpin.start('Stopping container...')
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/start.ts CHANGED
@@ -6,7 +6,7 @@ import { start } from '@/container'
6
6
  import { findAgentDir, isInitialized } from '@/init'
7
7
 
8
8
  import { guardIncompleteInit } from './incomplete-init'
9
- import { errorLine, renderStartSuccess, spinner } from './ui'
9
+ import { errorLine, renderStartSuccess, reportConfigWarnings, spinner } from './ui'
10
10
 
11
11
  export const startCommand = defineCommand({
12
12
  meta: {
@@ -61,6 +61,7 @@ export const startCommand = defineCommand({
61
61
  console.error(errorLine(validated.reason))
62
62
  process.exit(1)
63
63
  }
64
+ reportConfigWarnings(validated.warnings)
64
65
 
65
66
  const s = spinner()
66
67
  s.start('Starting container...')
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
@@ -222,6 +222,19 @@ export function successLine(message: string): string {
222
222
  return `${c.green('●')} ${message}`
223
223
  }
224
224
 
225
+ export function warnLine(message: string): string {
226
+ return `${c.yellow('⚠')} ${message}`
227
+ }
228
+
229
+ // Single host-side sink for non-fatal validateConfig warnings (e.g. a
230
+ // curl|bash docker.file.append line). Every CLI gate that calls validateConfig
231
+ // before a start/rebuild routes through this so no path silently drops them.
232
+ export function reportConfigWarnings(warnings: string[] | undefined): void {
233
+ for (const warning of warnings ?? []) {
234
+ console.warn(warnLine(warning))
235
+ }
236
+ }
237
+
225
238
  // The exact JSON manifest a user pastes into
226
239
  // https://api.slack.com/apps → From a manifest. Kept as a typed object so
227
240
  // the file stays a single source of truth and `JSON.stringify` guarantees
@@ -62,7 +62,7 @@ async function runOne(
62
62
  onStopped()
63
63
  const started = await start({ cwd, preferredHostPort, forceBuild, cliEntry })
64
64
  if (!started.ok) return { name, ok: false, reason: started.reason }
65
- return { name, ok: true, data: { stop: stopped, start: started } }
65
+ return { name, ok: true, data: { stop: stopped, start: started }, warnings: validated.warnings }
66
66
  } catch (error) {
67
67
  return { name, ok: false, reason: error instanceof Error ? error.message : String(error) }
68
68
  }
@@ -3,7 +3,9 @@ import { start, type StartResult } from '@/container'
3
3
 
4
4
  import { discoverAgents, type AgentEntry } from './discover'
5
5
 
6
- export type AgentResult<T> = { name: string; ok: true; data: T } | { name: string; ok: false; reason: string }
6
+ export type AgentResult<T> =
7
+ | { name: string; ok: true; data: T; warnings?: string[] }
8
+ | { name: string; ok: false; reason: string }
7
9
 
8
10
  export type StartSuccess = Extract<StartResult, { ok: true }>
9
11
 
@@ -55,7 +57,7 @@ async function runOne(
55
57
  try {
56
58
  const data = await start({ cwd, preferredHostPort, forceBuild, cliEntry })
57
59
  if (!data.ok) return { name, ok: false, reason: data.reason }
58
- return { name, ok: true, data }
60
+ return { name, ok: true, data, warnings: validated.warnings }
59
61
  } catch (error) {
60
62
  return { name, ok: false, reason: error instanceof Error ? error.message : String(error) }
61
63
  }