typeclaw 0.37.5 → 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/plugin-tools.ts +23 -1
- 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 +63 -2
- package/src/cli/compose.ts +11 -2
- package/src/cli/mount.ts +5 -5
- package/src/cli/restart.ts +2 -1
- package/src/cli/start.ts +2 -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 +200 -9
- package/src/cron/consumer.ts +3 -3
- package/src/init/dockerfile.ts +11 -8
|
@@ -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
|
@@ -607,6 +607,11 @@ type LiveSession = {
|
|
|
607
607
|
typingStartedAt: number
|
|
608
608
|
typingTimedOut: boolean
|
|
609
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
|
|
610
615
|
lastInboundAt: number
|
|
611
616
|
// Transcript-file size (bytes) captured immediately after cold-start, before
|
|
612
617
|
// any user turn — a proxy for the fixed base-context rebuild cost (rendered
|
|
@@ -1673,6 +1678,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1673
1678
|
typingStartedAt: 0,
|
|
1674
1679
|
typingTimedOut: false,
|
|
1675
1680
|
typingStopPromise: null,
|
|
1681
|
+
promptInFlight: false,
|
|
1676
1682
|
lastInboundAt: now(),
|
|
1677
1683
|
baseContextBytes: 0,
|
|
1678
1684
|
firstUnprocessedAt: 0,
|
|
@@ -1930,16 +1936,69 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1930
1936
|
live.typingStartedAt = now()
|
|
1931
1937
|
}
|
|
1932
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
|
+
|
|
1933
1992
|
const subscribeTypingActivity = (session: AgentSession, live: LiveSession): (() => void) => {
|
|
1934
1993
|
return session.subscribe((event) => {
|
|
1935
1994
|
if (event.type === 'tool_execution_end') {
|
|
1936
|
-
|
|
1995
|
+
reviveTypingActivity(live)
|
|
1937
1996
|
return
|
|
1938
1997
|
}
|
|
1939
1998
|
if (event.type !== 'message_update') return
|
|
1940
1999
|
const streamed = event.assistantMessageEvent.type
|
|
1941
2000
|
if (streamed === 'text_delta' || streamed === 'thinking_delta') {
|
|
1942
|
-
|
|
2001
|
+
reviveTypingActivity(live)
|
|
1943
2002
|
}
|
|
1944
2003
|
})
|
|
1945
2004
|
}
|
|
@@ -2338,6 +2397,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2338
2397
|
const isRealUserTurn = batch.length > 0
|
|
2339
2398
|
const retrievalContext = await fireSessionTurnStart(live, composeRetrievalQuery(batch))
|
|
2340
2399
|
const promptText = retrievalContext.results.length > 0 ? `${text}\n\n${retrievalContext.results}` : text
|
|
2400
|
+
live.promptInFlight = true
|
|
2341
2401
|
try {
|
|
2342
2402
|
await live.session.prompt(promptText)
|
|
2343
2403
|
await validateChannelTurn(live, successfulSendsBeforePrompt)
|
|
@@ -2349,6 +2409,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2349
2409
|
live.lastSentText.clear()
|
|
2350
2410
|
live.lastSendLeafId = null
|
|
2351
2411
|
} finally {
|
|
2412
|
+
live.promptInFlight = false
|
|
2352
2413
|
const sentReplyThisTurn = live.successfulChannelSends > successfulSendsBeforePrompt
|
|
2353
2414
|
if (sentReplyThisTurn) dropEngageReactionsAfterReply(live, engageAddPromises)
|
|
2354
2415
|
const emptyTurnRetryQueued = live.emptyTurnRetries > emptyTurnRetriesBeforePrompt
|
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/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...')
|
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/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
|
}
|