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.
- package/package.json +1 -1
- package/src/agent/doctor.ts +6 -1
- package/src/agent/plugin-tools.ts +23 -1
- package/src/agent/subagents.ts +146 -14
- package/src/agent/todo/scope.ts +4 -2
- package/src/agent/tools/channel-reply.ts +7 -9
- package/src/bundled-plugins/doc-render/index.ts +10 -0
- package/src/bundled-plugins/doc-render/skills/typeclaw-render-pdf/SKILL.md +171 -165
- package/src/bundled-plugins/doc-render/templates/lib.typ +339 -0
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +95 -11
- package/src/bundled-plugins/github-cli-auth/git-command.ts +11 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +68 -7
- package/src/bundled-plugins/memory/index.ts +9 -6
- package/src/bundled-plugins/memory/load-memory.ts +16 -2
- package/src/bundled-plugins/memory/slug.ts +19 -0
- package/src/bundled-plugins/security/policies/private-surface-read.ts +4 -1
- 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 +17 -0
- package/src/channels/manager.ts +77 -1
- package/src/channels/router.ts +181 -12
- package/src/cli/compose.ts +11 -2
- package/src/cli/dreams.ts +2 -2
- package/src/cli/inspect.ts +2 -2
- package/src/cli/logs.ts +2 -2
- package/src/cli/mount.ts +5 -5
- package/src/cli/require-agent-dir.ts +31 -0
- package/src/cli/restart.ts +2 -1
- package/src/cli/shell.ts +2 -2
- package/src/cli/start.ts +2 -1
- package/src/cli/stop.ts +2 -2
- package/src/cli/tui.ts +20 -6
- package/src/cli/ui.ts +13 -0
- package/src/compose/restart.ts +1 -1
- package/src/compose/start.ts +4 -2
- package/src/config/config.ts +200 -9
- package/src/container/shared.ts +18 -0
- package/src/container/start.ts +1 -1
- package/src/cron/consumer.ts +3 -3
- 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/init/dockerfile.ts +11 -8
- package/src/init/kakaotalk-auth.ts +2 -2
- package/src/init/packagejson.ts +2 -2
- package/src/plugin/loader.ts +7 -4
- package/src/sandbox/session-tmp.ts +6 -1
- package/src/secrets/export-claude-credentials-file.ts +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 {
|
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
119
|
-
// the
|
|
120
|
-
//
|
|
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
|
|
1892
|
-
|
|
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
|
-
|
|
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
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
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
|
package/src/cli/compose.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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/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/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
|
|
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
|
+
}
|
package/src/cli/restart.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
@@ -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
|
package/src/compose/restart.ts
CHANGED
|
@@ -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
|
}
|
package/src/compose/start.ts
CHANGED
|
@@ -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> =
|
|
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
|
}
|