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.
- package/README.md +69 -46
- package/package.json +1 -1
- package/src/agent/compaction.ts +24 -15
- package/src/agent/doctor.ts +6 -1
- package/src/agent/session-origin.ts +101 -173
- package/src/agent/subagents.ts +146 -14
- package/src/agent/system-prompt.ts +46 -48
- package/src/agent/todo/scope.ts +4 -2
- package/src/agent/tools/channel-reply.ts +7 -9
- package/src/bundled-plugins/memory/index.ts +33 -33
- package/src/bundled-plugins/memory/load-memory.ts +92 -35
- package/src/bundled-plugins/memory/slug.ts +19 -0
- package/src/bundled-plugins/memory/turn-dedup.ts +32 -29
- package/src/bundled-plugins/security/policies/private-surface-read.ts +4 -1
- 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/github/inbound.ts +68 -43
- package/src/channels/adapters/github/index.ts +57 -9
- package/src/channels/adapters/github/recover-failed-deliveries.ts +270 -0
- package/src/channels/adapters/kakaotalk.ts +5 -1
- package/src/channels/adapters/mention-hints.ts +75 -0
- package/src/channels/adapters/slack-bot.ts +8 -2
- package/src/channels/continuation-willingness.ts +216 -68
- package/src/channels/router.ts +149 -15
- package/src/cli/dreams.ts +2 -2
- package/src/cli/init.ts +41 -7
- package/src/cli/inspect.ts +2 -2
- package/src/cli/logs.ts +2 -2
- package/src/cli/qr.ts +4 -3
- package/src/cli/require-agent-dir.ts +31 -0
- package/src/cli/shell.ts +2 -2
- package/src/cli/stop.ts +2 -2
- package/src/cli/tui.ts +20 -6
- package/src/cli/ui.ts +8 -4
- package/src/container/shared.ts +18 -0
- package/src/container/start.ts +1 -1
- package/src/doctor/checks.ts +145 -2
- package/src/hostd/client.ts +48 -52
- package/src/hostd/daemon.ts +82 -39
- package/src/hostd/paths.ts +22 -2
- package/src/hostd/spawn.ts +7 -0
- package/src/hostd/tailscale.ts +12 -1
- package/src/init/index.ts +35 -8
- package/src/init/kakaotalk-auth.ts +2 -2
- package/src/init/packagejson.ts +2 -2
- package/src/init/run-bun-install.ts +71 -37
- package/src/inspect/transcript-view.ts +15 -2
- package/src/plugin/loader.ts +7 -4
- package/src/portbroker/hostd-client.ts +32 -6
- package/src/sandbox/session-tmp.ts +6 -1
- package/src/secrets/export-claude-credentials-file.ts +2 -2
- 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
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
114
|
-
// the
|
|
115
|
-
//
|
|
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
|
|
1887
|
-
|
|
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
|
-
|
|
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
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
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(
|
|
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 =
|
|
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(
|
|
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/inspect.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { defineCommand } from 'citty'
|
|
2
2
|
|
|
3
3
|
import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
|
|
4
|
-
import { findAgentDir } from '@/init'
|
|
5
4
|
import {
|
|
6
5
|
fetchLiveSessions,
|
|
7
6
|
listViewerItems,
|
|
@@ -20,6 +19,7 @@ import {
|
|
|
20
19
|
import { originLabel, shortSessionId } from '@/inspect/label'
|
|
21
20
|
|
|
22
21
|
import { createTailScope } from './inspect-controller'
|
|
22
|
+
import { requireAgentDir } from './require-agent-dir'
|
|
23
23
|
import { cancel, c, errorLine, isCancel, prepareStdinForClack } from './ui'
|
|
24
24
|
|
|
25
25
|
const ESC_DEBOUNCE_MS = 50
|
|
@@ -51,7 +51,7 @@ export const inspectCommand = defineCommand({
|
|
|
51
51
|
},
|
|
52
52
|
},
|
|
53
53
|
async run({ args }) {
|
|
54
|
-
const cwd =
|
|
54
|
+
const cwd = requireAgentDir()
|
|
55
55
|
const color = useColor()
|
|
56
56
|
const sessionArg = typeof args.session === 'string' ? args.session : undefined
|
|
57
57
|
const filterArg = typeof args.filter === 'string' ? args.filter : undefined
|
package/src/cli/logs.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { defineCommand } from 'citty'
|
|
2
2
|
|
|
3
3
|
import { logs, parseTailValue } from '@/container'
|
|
4
|
-
import { findAgentDir } from '@/init'
|
|
5
4
|
|
|
6
5
|
import { runInspectViewer } from './inspect'
|
|
6
|
+
import { requireAgentDir } from './require-agent-dir'
|
|
7
7
|
import { c, errorLine } from './ui'
|
|
8
8
|
|
|
9
9
|
export const logsCommand = defineCommand({
|
|
@@ -30,7 +30,7 @@ export const logsCommand = defineCommand({
|
|
|
30
30
|
},
|
|
31
31
|
},
|
|
32
32
|
async run({ args }) {
|
|
33
|
-
const cwd =
|
|
33
|
+
const cwd = requireAgentDir()
|
|
34
34
|
|
|
35
35
|
let tail: string | undefined
|
|
36
36
|
if (args.tail !== undefined) {
|
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
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { findAgentDir, isInitialized } from '@/init'
|
|
2
|
+
|
|
3
|
+
import { errorLine } from './ui'
|
|
4
|
+
|
|
5
|
+
export type RequiredAgentDir = { ok: true; cwd: string } | { ok: false; message: string }
|
|
6
|
+
|
|
7
|
+
const NOT_AN_AGENT_FOLDER = 'TypeClaw config file not found. Run `typeclaw init` first, or cd into an agent folder.'
|
|
8
|
+
|
|
9
|
+
// Operational host-stage commands (inspect, tui, logs, stop, shell, dreams) act
|
|
10
|
+
// on a specific agent's container or on-disk state, so they must run from inside
|
|
11
|
+
// an agent folder. The `findAgentDir(...) ?? startDir` fallback every caller used
|
|
12
|
+
// silently degraded these commands into an agent-less view — e.g. `inspect` would
|
|
13
|
+
// warn "container not running" and then offer only the container-logs row — instead
|
|
14
|
+
// of failing. Resolving to a fail result keeps the existence check explicit.
|
|
15
|
+
//
|
|
16
|
+
// Diagnostic commands (status, doctor, role list) deliberately tolerate a missing
|
|
17
|
+
// agent folder and must NOT use this gate.
|
|
18
|
+
export function resolveRequiredAgentDir(startDir: string): RequiredAgentDir {
|
|
19
|
+
const cwd = findAgentDir(startDir) ?? startDir
|
|
20
|
+
if (!isInitialized(cwd)) return { ok: false, message: NOT_AN_AGENT_FOLDER }
|
|
21
|
+
return { ok: true, cwd }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function requireAgentDir(startDir: string = process.cwd()): string {
|
|
25
|
+
const result = resolveRequiredAgentDir(startDir)
|
|
26
|
+
if (!result.ok) {
|
|
27
|
+
console.error(errorLine(result.message))
|
|
28
|
+
process.exit(1)
|
|
29
|
+
}
|
|
30
|
+
return result.cwd
|
|
31
|
+
}
|
package/src/cli/shell.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { defineCommand } from 'citty'
|
|
2
2
|
|
|
3
3
|
import { shell } from '@/container'
|
|
4
|
-
import { findAgentDir } from '@/init'
|
|
5
4
|
|
|
5
|
+
import { requireAgentDir } from './require-agent-dir'
|
|
6
6
|
import { c, errorLine } from './ui'
|
|
7
7
|
|
|
8
8
|
export const shellCommand = defineCommand({
|
|
@@ -18,7 +18,7 @@ export const shellCommand = defineCommand({
|
|
|
18
18
|
},
|
|
19
19
|
},
|
|
20
20
|
async run({ args }) {
|
|
21
|
-
const cwd =
|
|
21
|
+
const cwd = requireAgentDir()
|
|
22
22
|
|
|
23
23
|
console.log(c.cyan(`Attaching ${args.shell} inside the container...`))
|
|
24
24
|
|
package/src/cli/stop.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { defineCommand } from 'citty'
|
|
2
2
|
|
|
3
3
|
import { stop } from '@/container'
|
|
4
|
-
import { findAgentDir } from '@/init'
|
|
5
4
|
|
|
5
|
+
import { requireAgentDir } from './require-agent-dir'
|
|
6
6
|
import { c, spinner } from './ui'
|
|
7
7
|
|
|
8
8
|
export const stopCommand = defineCommand({
|
|
@@ -11,7 +11,7 @@ export const stopCommand = defineCommand({
|
|
|
11
11
|
description: 'stop the agent container (host stage)',
|
|
12
12
|
},
|
|
13
13
|
async run() {
|
|
14
|
-
const cwd =
|
|
14
|
+
const cwd = requireAgentDir()
|
|
15
15
|
|
|
16
16
|
const s = spinner()
|
|
17
17
|
s.start('Stopping container...')
|
package/src/cli/tui.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { defineCommand } from 'citty'
|
|
2
2
|
|
|
3
3
|
import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
|
|
4
|
-
import { findAgentDir } from '@/init'
|
|
5
4
|
import { CLI_VERSION } from '@/init/cli-version'
|
|
6
5
|
import { runTuiViewer } from '@/inspect'
|
|
7
6
|
import { formatVersionMismatchWarning } from '@/tui'
|
|
8
7
|
|
|
9
8
|
import { runInspectViewer } from './inspect'
|
|
9
|
+
import { requireAgentDir } from './require-agent-dir'
|
|
10
10
|
import { errorLine } from './ui'
|
|
11
11
|
|
|
12
12
|
export const tui = defineCommand({
|
|
@@ -27,9 +27,22 @@ export const tui = defineCommand({
|
|
|
27
27
|
},
|
|
28
28
|
},
|
|
29
29
|
async run({ args }) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
// An explicit --url targets an agent over the wire and needs no local agent
|
|
31
|
+
// folder — only the default-URL discovery and the esc-detach picker read
|
|
32
|
+
// local state. Require an agent folder only when --url is absent, mirroring
|
|
33
|
+
// how reload/cron/role claim gate their default-target path, not the whole
|
|
34
|
+
// command.
|
|
35
|
+
const explicitUrl = typeof args.url === 'string' ? args.url : undefined
|
|
36
|
+
let cwd: string | undefined
|
|
37
|
+
let resolveUrl: () => Promise<string>
|
|
38
|
+
if (explicitUrl === undefined) {
|
|
39
|
+
const agentDir = requireAgentDir()
|
|
40
|
+
cwd = agentDir
|
|
41
|
+
resolveUrl = () => defaultUrl(agentDir)
|
|
42
|
+
} else {
|
|
43
|
+
cwd = undefined
|
|
44
|
+
resolveUrl = async () => explicitUrl
|
|
45
|
+
}
|
|
33
46
|
|
|
34
47
|
const result = await runTuiViewer({
|
|
35
48
|
resolveUrl,
|
|
@@ -45,8 +58,9 @@ export const tui = defineCommand({
|
|
|
45
58
|
// can pick another session or the container logs — `tui` is just a deep-link
|
|
46
59
|
// into the session viewer, pre-opened on the live session. allowWritable
|
|
47
60
|
// is false because detaching ended the live session, so no row may be
|
|
48
|
-
// offered as a writable "live TUI" anymore.
|
|
49
|
-
|
|
61
|
+
// offered as a writable "live TUI" anymore. A --url-only run has no local
|
|
62
|
+
// agent folder to browse, so it exits instead of opening the local picker.
|
|
63
|
+
if (result.ok && result.escToPicker === true && cwd !== undefined) {
|
|
50
64
|
const viewerExit = await runInspectViewer({ cwd, allowWritable: false })
|
|
51
65
|
process.exit(viewerExit)
|
|
52
66
|
return
|
package/src/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/container/shared.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
1
2
|
import { basename, resolve } from 'node:path'
|
|
2
3
|
|
|
3
4
|
export type DockerExecResult = { exitCode: number; stdout: string; stderr: string }
|
|
@@ -249,8 +250,25 @@ export function imageTagFromCwd(cwd: string): string {
|
|
|
249
250
|
}
|
|
250
251
|
|
|
251
252
|
// Docker container names must match [a-zA-Z0-9][a-zA-Z0-9_.-]*.
|
|
253
|
+
//
|
|
254
|
+
// Non-ASCII names (Korean/CJK/Cyrillic/accented Latin — the common Windows case
|
|
255
|
+
// where the profile folder is a localized display name, e.g. C:\Users\사용자\봇)
|
|
256
|
+
// have every out-of-charset character collapsed to a dash, so distinct folders
|
|
257
|
+
// reduce to the SAME string: '봇' and '집' both → 'tc--'. The container name keys
|
|
258
|
+
// hostd registration and the secrets key path, so that collision is silent
|
|
259
|
+
// host-side state clobbering, not cosmetics. For any name carrying a non-ASCII
|
|
260
|
+
// char we append a deterministic hash of the original (cf. makeUntitledSlug in
|
|
261
|
+
// the memory plugin) to keep distinct folders distinct; surviving ASCII stays a
|
|
262
|
+
// readable prefix. ASCII-only names take the original branch — never renamed.
|
|
252
263
|
function sanitizeContainerName(name: string): string {
|
|
253
264
|
const cleaned = name.replace(/[^a-zA-Z0-9_.-]/g, '-')
|
|
265
|
+
if (/[^\u0000-\u007f]/.test(name)) {
|
|
266
|
+
const hash = createHash('sha256').update(name).digest('hex').slice(0, 8)
|
|
267
|
+
const remnant = cleaned.replace(/-+/g, '-').replace(/^-+|-+$/g, '')
|
|
268
|
+
if (remnant === '') return `tc-${hash}`
|
|
269
|
+
const base = /^[a-zA-Z0-9]/.test(remnant) ? remnant : `tc-${remnant}`
|
|
270
|
+
return `${base}-${hash}`
|
|
271
|
+
}
|
|
254
272
|
if (cleaned === '' || !/^[a-zA-Z0-9]/.test(cleaned)) {
|
|
255
273
|
return `tc-${cleaned || 'agent'}`
|
|
256
274
|
}
|
package/src/container/start.ts
CHANGED
|
@@ -671,7 +671,7 @@ export async function planStart({
|
|
|
671
671
|
// so mounting an empty cache would only invite a confusing local_files_only
|
|
672
672
|
// miss if something inside the container reached for the model anyway.
|
|
673
673
|
if (agentUsesVector(cwd)) {
|
|
674
|
-
runArgs.push('-v', `${homeRoot()}
|
|
674
|
+
runArgs.push('-v', `${join(homeRoot(), 'models')}:/opt/models:ro`)
|
|
675
675
|
runArgs.push('-e', 'TYPECLAW_MODEL_CACHE=/opt/models')
|
|
676
676
|
}
|
|
677
677
|
|