typeclaw 0.37.5 → 0.37.7
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 +2 -2
- package/src/agent/attention-escalation.ts +590 -0
- package/src/agent/plugin-tools.ts +23 -1
- package/src/agent/session-origin.ts +10 -7
- package/src/agent/subagents.ts +2 -0
- package/src/agent/system-prompt.ts +2 -2
- 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/channels/manager.ts +77 -1
- package/src/channels/router.ts +72 -3
- package/src/cli/channel.ts +1 -1
- package/src/cli/compose.ts +11 -2
- package/src/cli/init.ts +8 -1
- package/src/cli/mount.ts +5 -5
- package/src/cli/restart.ts +3 -1
- package/src/cli/start.ts +3 -1
- 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 +202 -9
- package/src/container/start.ts +17 -4
- package/src/cron/consumer.ts +10 -3
- package/src/doctor/checks.ts +13 -1
- package/src/init/dockerfile.ts +62 -11
- package/src/server/command-runner.ts +2 -0
- package/src/server/index.ts +9 -0
|
@@ -7,7 +7,7 @@ import { createApproveIdempotencyGuard } from './approve-idempotency'
|
|
|
7
7
|
import { createGithubEffectiveApprovalResolver, createGithubHeadShaResolver } from './effective-approval'
|
|
8
8
|
import { analyzeGhCommand, effectiveGhTokensForAuthenticatedUserEndpoint } from './gh-command'
|
|
9
9
|
import { ensureGitAskPassHelper } from './git-askpass'
|
|
10
|
-
import { analyzeGitCommand, defaultGitResolvers } from './git-command'
|
|
10
|
+
import { analyzeGitCommand, defaultGitResolvers, resolveGhDefaultRepoFromCwd } from './git-command'
|
|
11
11
|
import { checkGraphqlAuthNudge } from './graphql-auth-nudge'
|
|
12
12
|
import { commitReviewIfSucceeded, noteReviewCommand } from './review-recorder'
|
|
13
13
|
import { classifyGhToken, shouldMintAppToken } from './token-class'
|
|
@@ -72,6 +72,35 @@ export default definePlugin({
|
|
|
72
72
|
|
|
73
73
|
type HookResult = void | { block: true; reason: string }
|
|
74
74
|
|
|
75
|
+
// A TRUSTED repo to fill in for a repo-less `gh` command, resolved from
|
|
76
|
+
// sources the command author cannot forge: (1) a GitHub channel session's
|
|
77
|
+
// own repo (origin.workspace comes from the signed webhook payload), then
|
|
78
|
+
// (2) the working tree's `origin` remote. NOT from any `-R`/path in the
|
|
79
|
+
// command (that is the attacker-controllable input the parser already
|
|
80
|
+
// handles). The slug is still gated by the repos[] allowlist at mint time.
|
|
81
|
+
const resolveTrustedFallbackRepo = async (origin: SessionOrigin | undefined): Promise<string | undefined> => {
|
|
82
|
+
if (origin?.kind === 'channel' && origin.adapter === 'github' && origin.workspace !== '') {
|
|
83
|
+
return origin.workspace
|
|
84
|
+
}
|
|
85
|
+
const fromCwd = await resolveGhDefaultRepoFromCwd(ctx.agentDir, defaultGitResolvers)
|
|
86
|
+
return fromCwd ?? undefined
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// When a repo-less `gh` is blocked but a trusted repo IS available, show the
|
|
90
|
+
// exact single-bare rewrite so the agent recovers in one step instead of
|
|
91
|
+
// guessing. Composition blocks get a split-the-script instruction. The
|
|
92
|
+
// returned text is appended to the block reason (synchronous, always seen).
|
|
93
|
+
const buildGhBlockGuidance = (code: string, fallbackRepo: string | undefined): string => {
|
|
94
|
+
const slug = fallbackRepo ?? 'owner/repo'
|
|
95
|
+
if (code === 'composition') {
|
|
96
|
+
return (
|
|
97
|
+
` Run each gh as its own single bare command, e.g. \`gh label edit <name> -R ${slug} --name ...\` —` +
|
|
98
|
+
' not inside a function, `if`/`then`, `&&`, `;`, or `$(...)`.'
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
return ` For example: \`gh <cmd> -R ${slug}\` as a single bare command.`
|
|
102
|
+
}
|
|
103
|
+
|
|
75
104
|
// 'fall-through' means "not a repo-targeting gh command" so the caller can
|
|
76
105
|
// try the git path on the same command (e.g. `git ... # gh` substrings).
|
|
77
106
|
const handleGhCommand = async (params: {
|
|
@@ -91,7 +120,26 @@ export default definePlugin({
|
|
|
91
120
|
}
|
|
92
121
|
if (review.dump !== null) return review.dump
|
|
93
122
|
|
|
94
|
-
|
|
123
|
+
// Analyze first WITHOUT the fallback: an explicit `-R`/path repo must win,
|
|
124
|
+
// and we only pay for fallback resolution (a git subprocess) when the
|
|
125
|
+
// command is otherwise repo-less. A trusted fallback is then applied ONLY to
|
|
126
|
+
// a `missing-repo` block (never to composition/non-literal/multi-owner/api),
|
|
127
|
+
// and re-analysis re-runs the SAME composition gate, so a compound command
|
|
128
|
+
// still blocks. `fallbackRepoUsed` marks an inject that came from the
|
|
129
|
+
// fallback so we also set GH_REPO (gh needs the repo, not just the token).
|
|
130
|
+
let decision = analyzeGhCommand(command)
|
|
131
|
+
let fallbackRepo: string | undefined
|
|
132
|
+
let fallbackRepoUsed = false
|
|
133
|
+
if (decision.kind === 'block' && decision.code === 'missing-repo') {
|
|
134
|
+
fallbackRepo = await resolveTrustedFallbackRepo(event.origin)
|
|
135
|
+
if (fallbackRepo !== undefined) {
|
|
136
|
+
const withFallback = analyzeGhCommand(command, fallbackRepo)
|
|
137
|
+
if (withFallback.kind === 'inject') {
|
|
138
|
+
decision = withFallback
|
|
139
|
+
fallbackRepoUsed = true
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
95
143
|
|
|
96
144
|
// `/user` classifies as pass-through (no repo to mint for), so this block
|
|
97
145
|
// must run BEFORE the pass-through return. Resolve the EFFECTIVE token per
|
|
@@ -140,7 +188,10 @@ export default definePlugin({
|
|
|
140
188
|
// NOT apply — a PAT needs no per-repo mint — so we never surface it here.
|
|
141
189
|
if (runsUnsandboxed(event.origin)) {
|
|
142
190
|
if (decision.kind === 'inject') {
|
|
143
|
-
event.args[TYPECLAW_INTERNAL_BASH_ENV] = {
|
|
191
|
+
event.args[TYPECLAW_INTERNAL_BASH_ENV] = {
|
|
192
|
+
GH_TOKEN: process.env.GH_TOKEN as string,
|
|
193
|
+
...(fallbackRepoUsed && fallbackRepo !== undefined ? { GH_REPO: fallbackRepo } : {}),
|
|
194
|
+
}
|
|
144
195
|
}
|
|
145
196
|
return
|
|
146
197
|
}
|
|
@@ -148,14 +199,18 @@ export default definePlugin({
|
|
|
148
199
|
// available (a PAT must NOT suppress it, or the original silent-failure
|
|
149
200
|
// bug returns); otherwise block with guidance rather than failing mute.
|
|
150
201
|
if (!shouldMintAppToken(undefined, hasAppTokenResolver())) {
|
|
151
|
-
if (decision.kind === 'block')
|
|
202
|
+
if (decision.kind === 'block') {
|
|
203
|
+
return { block: true, reason: decision.reason + buildGhBlockGuidance(decision.code, fallbackRepo) }
|
|
204
|
+
}
|
|
152
205
|
warnSandboxedPatWithheldOnce()
|
|
153
206
|
return { block: true, reason: sandboxedPatWithheldReason }
|
|
154
207
|
}
|
|
155
208
|
mintForSandboxedPat = true
|
|
156
209
|
}
|
|
157
210
|
|
|
158
|
-
if (decision.kind === 'block')
|
|
211
|
+
if (decision.kind === 'block') {
|
|
212
|
+
return { block: true, reason: decision.reason + buildGhBlockGuidance(decision.code, fallbackRepo) }
|
|
213
|
+
}
|
|
159
214
|
|
|
160
215
|
// No App auth (no App-class GH_TOKEN and no live minter): leave whatever
|
|
161
216
|
// is seeded so `gh` fails honestly rather than us guessing a token. The
|
|
@@ -166,8 +221,14 @@ export default definePlugin({
|
|
|
166
221
|
if (result.kind === 'unavailable') return { block: true, reason: result.reason }
|
|
167
222
|
// Inject via the internal env overlay (delivered to the spawn / bwrap
|
|
168
223
|
// --setenv by the bash wrapper) so the token never enters the command
|
|
169
|
-
// string, where it could leak through logs or later hooks.
|
|
170
|
-
|
|
224
|
+
// string, where it could leak through logs or later hooks. When the repo
|
|
225
|
+
// came from a trusted fallback (not an explicit -R), also set GH_REPO so
|
|
226
|
+
// `gh` actually targets it — a token alone leaves the repo unresolved.
|
|
227
|
+
// GH_REPO is non-secret; the token still scopes reach to that repo.
|
|
228
|
+
event.args[TYPECLAW_INTERNAL_BASH_ENV] = {
|
|
229
|
+
GH_TOKEN: result.token,
|
|
230
|
+
...(fallbackRepoUsed ? { GH_REPO: decision.repoSlug } : {}),
|
|
231
|
+
}
|
|
171
232
|
return
|
|
172
233
|
}
|
|
173
234
|
|
package/src/channels/manager.ts
CHANGED
|
@@ -104,6 +104,17 @@ export type ChannelManagerOptions = {
|
|
|
104
104
|
// unregistered. See CreateChannelRouterOptions.onReload/onRestart.
|
|
105
105
|
onReload?: () => Promise<string>
|
|
106
106
|
onRestart?: (ctx?: RestartCommandContext) => Promise<string>
|
|
107
|
+
// Persistent messenger SDKs usually reconnect themselves, but a host sleep/offline
|
|
108
|
+
// cycle can leave a socket half-dead forever. The manager watches live adapters
|
|
109
|
+
// and restarts one that stays disconnected past this grace period. Test seams are
|
|
110
|
+
// optional so production uses normal timers/time.
|
|
111
|
+
connectionRecovery?: {
|
|
112
|
+
checkIntervalMs?: number
|
|
113
|
+
disconnectedGraceMs?: number
|
|
114
|
+
now?: () => number
|
|
115
|
+
setInterval?: (fn: () => void, ms: number) => unknown
|
|
116
|
+
clearInterval?: (handle: unknown) => void
|
|
117
|
+
}
|
|
107
118
|
}
|
|
108
119
|
|
|
109
120
|
export type ChannelManager = {
|
|
@@ -132,6 +143,8 @@ type AnyAdapter =
|
|
|
132
143
|
type AdapterEntry = {
|
|
133
144
|
adapter: AnyAdapter
|
|
134
145
|
credentialSignature: string
|
|
146
|
+
disconnectedSinceMs: number | null
|
|
147
|
+
recoveryRestartQueued: boolean
|
|
135
148
|
}
|
|
136
149
|
|
|
137
150
|
export function createChannelManager(options: ChannelManagerOptions): ChannelManager {
|
|
@@ -158,6 +171,14 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
158
171
|
|
|
159
172
|
const live = new Map<AdapterId, AdapterEntry>()
|
|
160
173
|
const perAdapterSerial = new Map<AdapterId, Promise<unknown>>()
|
|
174
|
+
const recovery = options.connectionRecovery ?? {}
|
|
175
|
+
const recoveryCheckIntervalMs = recovery.checkIntervalMs ?? 30_000
|
|
176
|
+
const recoveryDisconnectedGraceMs = recovery.disconnectedGraceMs ?? 90_000
|
|
177
|
+
const recoveryNow = recovery.now ?? (() => Date.now())
|
|
178
|
+
const recoverySetInterval = recovery.setInterval ?? ((fn: () => void, ms: number) => setInterval(fn, ms))
|
|
179
|
+
const recoveryClearInterval =
|
|
180
|
+
recovery.clearInterval ?? ((handle: unknown) => clearInterval(handle as ReturnType<typeof setInterval>))
|
|
181
|
+
let recoveryTimer: unknown = null
|
|
161
182
|
|
|
162
183
|
const runSerially = <T>(name: AdapterId, op: () => Promise<T>): Promise<T> => {
|
|
163
184
|
const prev = perAdapterSerial.get(name) ?? Promise.resolve()
|
|
@@ -271,7 +292,12 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
271
292
|
}
|
|
272
293
|
try {
|
|
273
294
|
await adapter.start()
|
|
274
|
-
live.set(name, {
|
|
295
|
+
live.set(name, {
|
|
296
|
+
adapter,
|
|
297
|
+
credentialSignature: signature,
|
|
298
|
+
disconnectedSinceMs: adapter.isConnected() ? null : recoveryNow(),
|
|
299
|
+
recoveryRestartQueued: false,
|
|
300
|
+
})
|
|
275
301
|
logger.info(`[channels] adapter "${name}" started`)
|
|
276
302
|
return true
|
|
277
303
|
} catch (err) {
|
|
@@ -292,6 +318,54 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
292
318
|
}
|
|
293
319
|
}
|
|
294
320
|
|
|
321
|
+
const checkConnectionRecovery = (): void => {
|
|
322
|
+
const now = recoveryNow()
|
|
323
|
+
for (const [name, entry] of live) {
|
|
324
|
+
if (entry.adapter.isConnected()) {
|
|
325
|
+
entry.disconnectedSinceMs = null
|
|
326
|
+
entry.recoveryRestartQueued = false
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
if (entry.disconnectedSinceMs === null) {
|
|
330
|
+
entry.disconnectedSinceMs = now
|
|
331
|
+
logger.warn(`[channels] adapter "${name}" is disconnected; waiting for SDK recovery`)
|
|
332
|
+
continue
|
|
333
|
+
}
|
|
334
|
+
const disconnectedForMs = now - entry.disconnectedSinceMs
|
|
335
|
+
if (disconnectedForMs < recoveryDisconnectedGraceMs || entry.recoveryRestartQueued) continue
|
|
336
|
+
entry.recoveryRestartQueued = true
|
|
337
|
+
logger.warn(
|
|
338
|
+
`[channels] adapter "${name}" disconnected for ${Math.round(disconnectedForMs)}ms; restarting adapter`,
|
|
339
|
+
)
|
|
340
|
+
void runSerially(name, async () => {
|
|
341
|
+
try {
|
|
342
|
+
const current = live.get(name)
|
|
343
|
+
if (current !== entry) return
|
|
344
|
+
const currentCfg = options.channelsConfigRef()[name]
|
|
345
|
+
if (currentCfg === undefined || currentCfg.enabled === false) {
|
|
346
|
+
logger.info(`[channels] recovery restart for "${name}" skipped; adapter no longer enabled`)
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
await stopAdapter(name)
|
|
350
|
+
await startAdapter(name, currentCfg)
|
|
351
|
+
} finally {
|
|
352
|
+
if (live.get(name) === entry) entry.recoveryRestartQueued = false
|
|
353
|
+
}
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const startRecoveryTimer = (): void => {
|
|
359
|
+
if (recoveryTimer !== null) return
|
|
360
|
+
recoveryTimer = recoverySetInterval(checkConnectionRecovery, recoveryCheckIntervalMs)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const stopRecoveryTimer = (): void => {
|
|
364
|
+
if (recoveryTimer === null) return
|
|
365
|
+
recoveryClearInterval(recoveryTimer)
|
|
366
|
+
recoveryTimer = null
|
|
367
|
+
}
|
|
368
|
+
|
|
295
369
|
return {
|
|
296
370
|
router,
|
|
297
371
|
|
|
@@ -313,9 +387,11 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
313
387
|
const results = await Promise.allSettled(starts)
|
|
314
388
|
const failure = results.find((r): r is PromiseRejectedResult => r.status === 'rejected')
|
|
315
389
|
if (failure !== undefined) throw failure.reason
|
|
390
|
+
startRecoveryTimer()
|
|
316
391
|
},
|
|
317
392
|
|
|
318
393
|
async stop(): Promise<void> {
|
|
394
|
+
stopRecoveryTimer()
|
|
319
395
|
for (const name of Array.from(live.keys())) await runSerially(name, () => stopAdapter(name))
|
|
320
396
|
await router.stop()
|
|
321
397
|
},
|
package/src/channels/router.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { AssistantMessage } from '@mariozechner/pi-ai'
|
|
|
5
5
|
import { SessionManager } from '@mariozechner/pi-coding-agent'
|
|
6
6
|
|
|
7
7
|
import { createSession, renderTurnRoleAnchor, renderTurnTimeAnchor, type AgentSession } from '@/agent'
|
|
8
|
+
import { applyTurnThinkingLevel } from '@/agent/attention-escalation'
|
|
8
9
|
import { forgetSharedLoopGuardTool } from '@/agent/plugin-tools'
|
|
9
10
|
import { subscribeProviderErrors } from '@/agent/provider-error'
|
|
10
11
|
import type { RestartHandoff } from '@/agent/restart-handoff'
|
|
@@ -573,6 +574,10 @@ type LiveSession = {
|
|
|
573
574
|
key: ChannelKey
|
|
574
575
|
keyId: string
|
|
575
576
|
session: AgentSession
|
|
577
|
+
// The session's creation-time thinking level, captured once. A later escalated
|
|
578
|
+
// turn moves `session.thinkingLevel` to `high`, so the live getter can't be the
|
|
579
|
+
// reset target — this preserves the real default across the session's lifetime.
|
|
580
|
+
turnThinkingDefault: AgentSession['thinkingLevel']
|
|
576
581
|
sessionId: string
|
|
577
582
|
dispose: () => Promise<void>
|
|
578
583
|
hooks: HookBus | undefined
|
|
@@ -607,6 +612,11 @@ type LiveSession = {
|
|
|
607
612
|
typingStartedAt: number
|
|
608
613
|
typingTimedOut: boolean
|
|
609
614
|
typingStopPromise: Promise<void> | null
|
|
615
|
+
// True only while `live.session.prompt()` is actively running. Gates the
|
|
616
|
+
// deferred typing revival: a revival queued behind an in-flight cap-trip
|
|
617
|
+
// 'stop' must NOT re-arm the heartbeat once the prompt has finished, or it
|
|
618
|
+
// leaks a timer that fires past the turn's end.
|
|
619
|
+
promptInFlight: boolean
|
|
610
620
|
lastInboundAt: number
|
|
611
621
|
// Transcript-file size (bytes) captured immediately after cold-start, before
|
|
612
622
|
// any user turn — a proxy for the fixed base-context rebuild cost (rendered
|
|
@@ -1654,6 +1664,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1654
1664
|
key,
|
|
1655
1665
|
keyId,
|
|
1656
1666
|
session: created.session,
|
|
1667
|
+
turnThinkingDefault: created.session.thinkingLevel,
|
|
1657
1668
|
sessionId: created.sessionId,
|
|
1658
1669
|
dispose: created.dispose,
|
|
1659
1670
|
hooks: created.hooks,
|
|
@@ -1673,6 +1684,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1673
1684
|
typingStartedAt: 0,
|
|
1674
1685
|
typingTimedOut: false,
|
|
1675
1686
|
typingStopPromise: null,
|
|
1687
|
+
promptInFlight: false,
|
|
1676
1688
|
lastInboundAt: now(),
|
|
1677
1689
|
baseContextBytes: 0,
|
|
1678
1690
|
firstUnprocessedAt: 0,
|
|
@@ -1930,16 +1942,69 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1930
1942
|
live.typingStartedAt = now()
|
|
1931
1943
|
}
|
|
1932
1944
|
|
|
1945
|
+
// Re-arm a heartbeat that the silence cap already stopped. PR #930 made
|
|
1946
|
+
// streamed deltas refresh the clock, but only via `bumpTypingActivity`,
|
|
1947
|
+
// which no-ops once `typingTimer` is null. So a turn that goes silent for
|
|
1948
|
+
// >MAX_TYPING_HEARTBEAT_MS (a long single tool call, a slow provider, an
|
|
1949
|
+
// extended-thinking phase that emits no deltas) trips the cap, and the
|
|
1950
|
+
// delta/tool signals that arrive afterwards can no longer revive it —
|
|
1951
|
+
// `startTypingHeartbeat` short-circuits on `typingTimedOut`, which only
|
|
1952
|
+
// resets at the next drain() iteration (i.e. never, within one long turn).
|
|
1953
|
+
// Reviving here lets demonstrable progress after a timeout bring the
|
|
1954
|
+
// indicator back. The revival is gated on streamed activity ONLY, never on
|
|
1955
|
+
// a new inbound (a later inbound during the same in-flight turn must stay
|
|
1956
|
+
// silenced — see the matching router test).
|
|
1957
|
+
//
|
|
1958
|
+
// On Slack this is the visible bug: its `'stop'` phase sends a permanent
|
|
1959
|
+
// empty-string `setStatus` clear that does not auto-expire, so a tripped
|
|
1960
|
+
// indicator stays gone. Discord/Telegram mask the same defect because their
|
|
1961
|
+
// native indicators auto-expire and self-heal on the next tick.
|
|
1962
|
+
const reviveTypingActivity = (live: LiveSession): void => {
|
|
1963
|
+
if (live.destroyed) return
|
|
1964
|
+
if (!live.typingTimedOut) {
|
|
1965
|
+
bumpTypingActivity(live)
|
|
1966
|
+
return
|
|
1967
|
+
}
|
|
1968
|
+
// A `'stop'` clear may still be in flight. Starting a new heartbeat now
|
|
1969
|
+
// would short-circuit on the non-null `typingStopPromise` (losing the
|
|
1970
|
+
// revival), and firing a fresh `'tick'` before Slack's empty-string clear
|
|
1971
|
+
// lands would let the clear wipe the just-revived status. Defer until the
|
|
1972
|
+
// stop settles so the new `'tick'` is ordered strictly after the clear.
|
|
1973
|
+
const stopPromise = live.typingStopPromise
|
|
1974
|
+
if (stopPromise) {
|
|
1975
|
+
void stopPromise.catch(() => undefined).then(() => restartTypingAfterTimeout(live))
|
|
1976
|
+
return
|
|
1977
|
+
}
|
|
1978
|
+
restartTypingAfterTimeout(live)
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
const restartTypingAfterTimeout = (live: LiveSession): void => {
|
|
1982
|
+
// Re-checked after the awaited stop:
|
|
1983
|
+
// - `destroyed`: the session may have been torn down.
|
|
1984
|
+
// - `!typingTimedOut`: an earlier queued revival already cleared the flag
|
|
1985
|
+
// and re-armed (so this one is a no-op — no double interval/tick).
|
|
1986
|
+
// - `!promptInFlight`: the prompt finished while the cap-trip 'stop' was
|
|
1987
|
+
// still in flight. A revival queued by a late delta would otherwise
|
|
1988
|
+
// re-arm a heartbeat after generation ended — a timer nothing stops
|
|
1989
|
+
// (drain()'s turn-end stop already ran with `typingTimer === null`).
|
|
1990
|
+
// `draining` is too coarse: it stays true through the turn-end hook tail
|
|
1991
|
+
// (turn-end/idle/todos) after `prompt()` returns, so a revival running
|
|
1992
|
+
// in that window would still leak. Gate on active generation instead.
|
|
1993
|
+
if (live.destroyed || !live.promptInFlight || !live.typingTimedOut) return
|
|
1994
|
+
live.typingTimedOut = false
|
|
1995
|
+
startTypingHeartbeat(live)
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1933
1998
|
const subscribeTypingActivity = (session: AgentSession, live: LiveSession): (() => void) => {
|
|
1934
1999
|
return session.subscribe((event) => {
|
|
1935
2000
|
if (event.type === 'tool_execution_end') {
|
|
1936
|
-
|
|
2001
|
+
reviveTypingActivity(live)
|
|
1937
2002
|
return
|
|
1938
2003
|
}
|
|
1939
2004
|
if (event.type !== 'message_update') return
|
|
1940
2005
|
const streamed = event.assistantMessageEvent.type
|
|
1941
2006
|
if (streamed === 'text_delta' || streamed === 'thinking_delta') {
|
|
1942
|
-
|
|
2007
|
+
reviveTypingActivity(live)
|
|
1943
2008
|
}
|
|
1944
2009
|
})
|
|
1945
2010
|
}
|
|
@@ -2336,8 +2401,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2336
2401
|
live.policyDeniedToolSendsThisTurn.clear()
|
|
2337
2402
|
resetReviewTurn(live.sessionId)
|
|
2338
2403
|
const isRealUserTurn = batch.length > 0
|
|
2339
|
-
const
|
|
2404
|
+
const retrievalQuery = composeRetrievalQuery(batch)
|
|
2405
|
+
const retrievalContext = await fireSessionTurnStart(live, retrievalQuery)
|
|
2340
2406
|
const promptText = retrievalContext.results.length > 0 ? `${text}\n\n${retrievalContext.results}` : text
|
|
2407
|
+
applyTurnThinkingLevel(live.session, retrievalQuery, live.turnThinkingDefault)
|
|
2408
|
+
live.promptInFlight = true
|
|
2341
2409
|
try {
|
|
2342
2410
|
await live.session.prompt(promptText)
|
|
2343
2411
|
await validateChannelTurn(live, successfulSendsBeforePrompt)
|
|
@@ -2349,6 +2417,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2349
2417
|
live.lastSentText.clear()
|
|
2350
2418
|
live.lastSendLeafId = null
|
|
2351
2419
|
} finally {
|
|
2420
|
+
live.promptInFlight = false
|
|
2352
2421
|
const sentReplyThisTurn = live.successfulChannelSends > successfulSendsBeforePrompt
|
|
2353
2422
|
if (sentReplyThisTurn) dropEngageReactionsAfterReply(live, engageAddPromises)
|
|
2354
2423
|
const emptyTurnRetryQueued = live.emptyTurnRetries > emptyTurnRetriesBeforePrompt
|
package/src/cli/channel.ts
CHANGED
|
@@ -1116,7 +1116,7 @@ async function promptDiscordToken(): Promise<string> {
|
|
|
1116
1116
|
[
|
|
1117
1117
|
'https://discord.com/developers/applications',
|
|
1118
1118
|
'New Application → Bot tab → Reset Token.',
|
|
1119
|
-
'
|
|
1119
|
+
'Under Privileged Gateway Intents, enable MESSAGE CONTENT and GUILD MEMBERS.',
|
|
1120
1120
|
].join('\n'),
|
|
1121
1121
|
'Get a Discord bot token',
|
|
1122
1122
|
)
|
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/init.ts
CHANGED
|
@@ -554,6 +554,7 @@ export async function collectWizardInputs(
|
|
|
554
554
|
const decision = await prompts.confirmResumeCheckpoint(sanitized)
|
|
555
555
|
if (decision === 'resume') {
|
|
556
556
|
seedWizardState(state, sanitized)
|
|
557
|
+
step = resumeStep(state)
|
|
557
558
|
} else {
|
|
558
559
|
await checkpointStore.clear(cwd)
|
|
559
560
|
}
|
|
@@ -1030,6 +1031,12 @@ function resolveModelOption(catalog: WizardState['catalog'], ref: string | undef
|
|
|
1030
1031
|
return catalog.options.find((option) => option.ref === ref)
|
|
1031
1032
|
}
|
|
1032
1033
|
|
|
1034
|
+
function resumeStep(state: WizardState): StepId {
|
|
1035
|
+
if (state.providerId !== undefined) return 'reuse-existing-key'
|
|
1036
|
+
if (state.vendorId !== undefined) return 'pick-provider-variant'
|
|
1037
|
+
return 'pick-vendor'
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1033
1040
|
function projectCheckpoint(cwd: string, state: WizardState): WizardAnswerCheckpointV1 {
|
|
1034
1041
|
return checkpointFromSelections({
|
|
1035
1042
|
cwd,
|
|
@@ -1429,7 +1436,7 @@ async function runDiscordFlow(): Promise<StepResult<CollectedInputs['channelSecr
|
|
|
1429
1436
|
[
|
|
1430
1437
|
'https://discord.com/developers/applications',
|
|
1431
1438
|
'New Application → Bot tab → Reset Token.',
|
|
1432
|
-
'
|
|
1439
|
+
'Under Privileged Gateway Intents, enable MESSAGE CONTENT and GUILD MEMBERS.',
|
|
1433
1440
|
].join('\n'),
|
|
1434
1441
|
'Get a Discord bot token',
|
|
1435
1442
|
)
|
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,
|
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...')
|
|
@@ -85,6 +86,7 @@ export const restartCommand = defineCommand({
|
|
|
85
86
|
}
|
|
86
87
|
startSpin.stop('Started.')
|
|
87
88
|
|
|
89
|
+
reportConfigWarnings(started.dockerfileWarnings)
|
|
88
90
|
console.log(renderStartSuccess(started))
|
|
89
91
|
},
|
|
90
92
|
})
|
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...')
|
|
@@ -76,6 +77,7 @@ export const startCommand = defineCommand({
|
|
|
76
77
|
}
|
|
77
78
|
s.stop(result.alreadyRunning ? 'Already running.' : 'Started.')
|
|
78
79
|
|
|
80
|
+
reportConfigWarnings(result.dockerfileWarnings)
|
|
79
81
|
console.log(renderStartSuccess(result))
|
|
80
82
|
},
|
|
81
83
|
})
|
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
|
}
|