typeclaw 0.26.0 → 0.28.0
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/scripts/generate-schema.ts +4 -6
- package/src/agent/index.ts +26 -4
- package/src/agent/multimodal/look-at.ts +1 -2
- package/src/agent/session-origin.ts +9 -1
- package/src/agent/tools/channel-fetch-attachment.ts +1 -2
- package/src/agent/tools/channel-react.ts +9 -3
- package/src/agent/tools/channel-reply.ts +30 -1
- package/src/agent/tools/channel-send.ts +94 -1
- package/src/bundled-plugins/github-cli-auth/gh-review-detect.ts +175 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +4 -0
- package/src/bundled-plugins/github-cli-auth/review-recorder.ts +93 -0
- package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
- package/src/bundled-plugins/memory/README.md +3 -21
- package/src/bundled-plugins/memory/index.ts +1 -149
- package/src/bundled-plugins/reviewer/skills/code-review.ts +3 -1
- package/src/bundled-plugins/security/policies/cron-promotion.ts +2 -2
- package/src/channels/adapters/github/inbound.ts +155 -9
- package/src/channels/adapters/github/review-thread-resolver.ts +93 -8
- package/src/channels/github-false-receipt.ts +87 -0
- package/src/channels/github-review-claim.ts +91 -0
- package/src/channels/github-review-turn-ledger.ts +71 -0
- package/src/channels/persistence.ts +4 -102
- package/src/channels/router.ts +191 -7
- package/src/channels/schema.ts +20 -5
- package/src/cli/channel.ts +2 -1
- package/src/cli/init.ts +2 -1
- package/src/cli/inspect.ts +216 -36
- package/src/cli/logs.ts +15 -0
- package/src/cli/tui.ts +33 -39
- package/src/compose/logs.ts +1 -1
- package/src/config/config.ts +19 -288
- package/src/container/logs.ts +70 -22
- package/src/container/start.ts +0 -2
- package/src/cron/index.ts +3 -44
- package/src/cron/schema.ts +2 -96
- package/src/init/gitignore.ts +1 -2
- package/src/inspect/index.ts +128 -42
- package/src/inspect/item-list.ts +44 -0
- package/src/inspect/item.ts +17 -0
- package/src/inspect/label.ts +1 -1
- package/src/inspect/logs-item.ts +79 -0
- package/src/inspect/loop.ts +74 -3
- package/src/inspect/open-item.ts +100 -0
- package/src/inspect/preview.ts +106 -0
- package/src/inspect/session-list.ts +15 -3
- package/src/inspect/transcript-view.ts +182 -0
- package/src/inspect/tui-item.ts +97 -0
- package/src/secrets/defaults.ts +1 -18
- package/src/secrets/index.ts +0 -2
- package/src/secrets/schema.ts +4 -90
- package/src/secrets/storage.ts +0 -2
- package/src/server/index.ts +0 -4
- package/src/skills/typeclaw-channel-github/SKILL.md +3 -1
- package/src/skills/typeclaw-config/SKILL.md +9 -11
- package/src/skills/typeclaw-permissions/SKILL.md +1 -1
- package/src/tui/index.ts +72 -32
- package/typeclaw.schema.json +1 -0
- package/src/agent/tools/normalize-ref.ts +0 -11
- package/src/bundled-plugins/memory/migration.ts +0 -633
- package/src/secrets/migrate-kakaotalk.ts +0 -82
- package/src/secrets/migrate.ts +0 -96
package/src/channels/router.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
StickyLedger,
|
|
33
33
|
type EngagementDecision,
|
|
34
34
|
} from './engagement'
|
|
35
|
+
import { resetReviewTurn } from './github-review-turn-ledger'
|
|
35
36
|
import {
|
|
36
37
|
MEMBERSHIP_COLD_FETCH_TIMEOUT_MS,
|
|
37
38
|
type MembershipCount,
|
|
@@ -1844,6 +1845,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1844
1845
|
live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
|
|
1845
1846
|
live.skipLockedSendTurn = null
|
|
1846
1847
|
live.policyDeniedToolSendsThisTurn.clear()
|
|
1848
|
+
resetReviewTurn(live.sessionId)
|
|
1847
1849
|
const isRealUserTurn = batch.length > 0
|
|
1848
1850
|
await fireSessionTurnStart(live, text)
|
|
1849
1851
|
try {
|
|
@@ -2854,7 +2856,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2854
2856
|
return
|
|
2855
2857
|
}
|
|
2856
2858
|
|
|
2857
|
-
const { text:
|
|
2859
|
+
const { text: candidateText, source } = candidate
|
|
2860
|
+
let assistantText = candidateText
|
|
2858
2861
|
|
|
2859
2862
|
if (endsWithNoReplySignal(assistantText)) {
|
|
2860
2863
|
const leakedReasoning = !isNoReplySignal(assistantText)
|
|
@@ -2874,10 +2877,49 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2874
2877
|
return
|
|
2875
2878
|
}
|
|
2876
2879
|
|
|
2877
|
-
|
|
2878
|
-
|
|
2880
|
+
// Plain-text tool-call leak: the model serialized a channel tool call as
|
|
2881
|
+
// ordinary prose instead of producing a real tool call (a Kimi-on-Fireworks
|
|
2882
|
+
// failure mode — see `isLikelyPlainTextChannelToolCall`). We can't post the
|
|
2883
|
+
// raw `channel_reply({...})` serialization to the channel, but for
|
|
2884
|
+
// reply/send the model's *intent* is unambiguous: deliver the `text` arg.
|
|
2885
|
+
// Extract it and recover the actual message. `skip_response` is the
|
|
2886
|
+
// opposite — a genuine decline — so it stays suppressed.
|
|
2887
|
+
const plainTextToolCallKind = getPlainTextChannelToolCallKind(assistantText)
|
|
2888
|
+
if (plainTextToolCallKind === 'skip') {
|
|
2889
|
+
logger.warn(
|
|
2890
|
+
`[channels] ${live.keyId}: suppressed plain_text_channel_skip_response text_len=${assistantText.length}`,
|
|
2891
|
+
)
|
|
2879
2892
|
return
|
|
2880
2893
|
}
|
|
2894
|
+
if (plainTextToolCallKind !== null) {
|
|
2895
|
+
const extracted = extractPlainTextChannelToolCallText(assistantText)
|
|
2896
|
+
// Unextractable (no `text` arg, empty value, or fully-truncated): fall
|
|
2897
|
+
// back to the historical safe behavior — drop it rather than leak plumbing.
|
|
2898
|
+
if (extracted === null) {
|
|
2899
|
+
logger.warn(
|
|
2900
|
+
`[channels] ${live.keyId}: suppressed unextractable_plain_text_channel_tool_call text_len=${assistantText.length}`,
|
|
2901
|
+
)
|
|
2902
|
+
return
|
|
2903
|
+
}
|
|
2904
|
+
// The extracted value is still untrusted model output: if it is itself a
|
|
2905
|
+
// no-reply signal, an empty-response sentinel, or another (nested) leaked
|
|
2906
|
+
// tool call, suppress it through the same guards rather than re-leaking.
|
|
2907
|
+
if (
|
|
2908
|
+
endsWithNoReplySignal(extracted) ||
|
|
2909
|
+
isUpstreamEmptyResponseSentinel(extracted) ||
|
|
2910
|
+
isLikelyKimiChannelToolLeak(extracted) ||
|
|
2911
|
+
isLikelyPlainTextChannelToolCall(extracted)
|
|
2912
|
+
) {
|
|
2913
|
+
logger.warn(
|
|
2914
|
+
`[channels] ${live.keyId}: suppressed plain_text_channel_tool_call (unsafe extracted text) text_len=${extracted.length}`,
|
|
2915
|
+
)
|
|
2916
|
+
return
|
|
2917
|
+
}
|
|
2918
|
+
logger.warn(
|
|
2919
|
+
`[channels] ${live.keyId}: recovered plain_text_channel_tool_call kind=${plainTextToolCallKind} text_len=${extracted.length}`,
|
|
2920
|
+
)
|
|
2921
|
+
assistantText = extracted
|
|
2922
|
+
}
|
|
2881
2923
|
|
|
2882
2924
|
// `source` distinguishes the three recovery shapes for log triage:
|
|
2883
2925
|
// - 'leaf': the assistant message IS the leaf with stopReason 'stop'
|
|
@@ -4233,8 +4275,12 @@ export function isLikelyKimiChannelToolLeak(text: string): boolean {
|
|
|
4233
4275
|
//
|
|
4234
4276
|
// Structural-only detection (NOT a substring search): the trimmed text must
|
|
4235
4277
|
// *start* with `channel_reply(`, `channel_send(`, or `skip_response(`, and
|
|
4236
|
-
// that opening paren must enclose at least one `"` (the
|
|
4237
|
-
//
|
|
4278
|
+
// that opening paren must enclose at least one quote — `"` or `'` (the
|
|
4279
|
+
// serialized argument). The single-quote arm matters because the extractor
|
|
4280
|
+
// recovers single-quoted values too; if the classifier only matched `"`, a
|
|
4281
|
+
// single-quoted leak like `channel_reply({text: 'hi'})` would bypass the
|
|
4282
|
+
// extractor and post raw plumbing. This deliberately matches the leak shape
|
|
4283
|
+
// while letting prose that merely
|
|
4238
4284
|
// *mentions* a tool name (e.g. "I would normally call channel_reply here
|
|
4239
4285
|
// but...") reach the user — that false-positive class is already locked in by
|
|
4240
4286
|
// the `still recovers prose that mentions channel_reply` test.
|
|
@@ -4242,12 +4288,150 @@ export function isLikelyKimiChannelToolLeak(text: string): boolean {
|
|
|
4242
4288
|
// The trailing close paren is NOT required: the model sometimes truncates
|
|
4243
4289
|
// mid-serialization, and a half-leaked `channel_reply({"text":"..."` is
|
|
4244
4290
|
// just as user-hostile as the full shape.
|
|
4245
|
-
const PLAIN_TEXT_CHANNEL_TOOL_CALL_RE = /^(
|
|
4291
|
+
const PLAIN_TEXT_CHANNEL_TOOL_CALL_RE = /^(channel_reply|channel_send|skip_response)\s*\(\s*[^)]*["']/
|
|
4292
|
+
|
|
4293
|
+
export type PlainTextChannelToolCallKind = 'reply' | 'send' | 'skip'
|
|
4294
|
+
|
|
4295
|
+
export function getPlainTextChannelToolCallKind(text: string): PlainTextChannelToolCallKind | null {
|
|
4296
|
+
const match = PLAIN_TEXT_CHANNEL_TOOL_CALL_RE.exec(text.trim())
|
|
4297
|
+
if (match === null) return null
|
|
4298
|
+
switch (match[1]) {
|
|
4299
|
+
case 'channel_reply':
|
|
4300
|
+
return 'reply'
|
|
4301
|
+
case 'channel_send':
|
|
4302
|
+
return 'send'
|
|
4303
|
+
case 'skip_response':
|
|
4304
|
+
return 'skip'
|
|
4305
|
+
default:
|
|
4306
|
+
return null
|
|
4307
|
+
}
|
|
4308
|
+
}
|
|
4246
4309
|
|
|
4247
4310
|
export function isLikelyPlainTextChannelToolCall(text: string): boolean {
|
|
4248
|
-
return
|
|
4311
|
+
return getPlainTextChannelToolCallKind(text) !== null
|
|
4312
|
+
}
|
|
4313
|
+
|
|
4314
|
+
// Tolerant single-purpose scanner that pulls the `text` argument out of a
|
|
4315
|
+
// plain-text-serialized `channel_reply(...)` / `channel_send(...)` leak. A
|
|
4316
|
+
// single regex covering every shape (double/single/unquoted keys, escaped
|
|
4317
|
+
// quotes, mid-serialization truncation) is fragile, so this walks the string
|
|
4318
|
+
// once and extracts only the first string-valued `text` property. `channel_send`
|
|
4319
|
+
// also carries `adapter`/`chat`/`thread`, which are intentionally ignored —
|
|
4320
|
+
// recovery always routes back through the current channel, never a
|
|
4321
|
+
// model-supplied destination. Returns null when no recoverable, non-empty
|
|
4322
|
+
// `text` value is present so the caller can fall back to suppression.
|
|
4323
|
+
export function extractPlainTextChannelToolCallText(text: string): string | null {
|
|
4324
|
+
const trimmed = text.trim()
|
|
4325
|
+
if (!/^(?:channel_reply|channel_send)\s*\(/.test(trimmed)) return null
|
|
4326
|
+
|
|
4327
|
+
// Walk the serialization once, honoring a `text` key only at the top level of
|
|
4328
|
+
// the argument object (braceDepth 1, outside any array). Two failure classes
|
|
4329
|
+
// motivate the bookkeeping: a `text:` inside an earlier quoted value, e.g.
|
|
4330
|
+
// `channel_send({ reason: "see text: here", text: "real" })`, and a `text:`
|
|
4331
|
+
// inside a *nested* object, e.g. `channel_reply({ meta: { text: "x" }, text:
|
|
4332
|
+
// "real" })`. Skipping string literals defeats the first; tracking
|
|
4333
|
+
// brace/bracket depth and matching keys only at top level defeats the second.
|
|
4334
|
+
// Either way the scanner lands on the real reply instead of leaking the wrong
|
|
4335
|
+
// value or dropping the message.
|
|
4336
|
+
let braceDepth = 0
|
|
4337
|
+
let bracketDepth = 0
|
|
4338
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
4339
|
+
const ch = trimmed[i]!
|
|
4340
|
+
|
|
4341
|
+
if (ch === '"' || ch === "'") {
|
|
4342
|
+
i = skipStringLiteral(trimmed, i, ch)
|
|
4343
|
+
continue
|
|
4344
|
+
}
|
|
4345
|
+
|
|
4346
|
+
if (ch === '{') {
|
|
4347
|
+
braceDepth++
|
|
4348
|
+
if (braceDepth === 1 && bracketDepth === 0) {
|
|
4349
|
+
const value = readTextKeyValueAt(trimmed, i + 1)
|
|
4350
|
+
if (value !== undefined) return value
|
|
4351
|
+
}
|
|
4352
|
+
continue
|
|
4353
|
+
}
|
|
4354
|
+
if (ch === '}') {
|
|
4355
|
+
if (braceDepth > 0) braceDepth--
|
|
4356
|
+
continue
|
|
4357
|
+
}
|
|
4358
|
+
if (ch === '[') {
|
|
4359
|
+
bracketDepth++
|
|
4360
|
+
continue
|
|
4361
|
+
}
|
|
4362
|
+
if (ch === ']') {
|
|
4363
|
+
if (bracketDepth > 0) bracketDepth--
|
|
4364
|
+
continue
|
|
4365
|
+
}
|
|
4366
|
+
|
|
4367
|
+
if (ch === ',' && braceDepth === 1 && bracketDepth === 0) {
|
|
4368
|
+
const value = readTextKeyValueAt(trimmed, i + 1)
|
|
4369
|
+
if (value !== undefined) return value
|
|
4370
|
+
}
|
|
4371
|
+
}
|
|
4372
|
+
|
|
4373
|
+
return null
|
|
4374
|
+
}
|
|
4375
|
+
|
|
4376
|
+
// Returns the recovered value (string or null) when a `text` key starts at
|
|
4377
|
+
// `from`, or undefined when no `text` key is present there so the scanner keeps
|
|
4378
|
+
// walking. The null/undefined split lets a malformed `text` value short-circuit
|
|
4379
|
+
// to suppression while a non-`text` delimiter is simply skipped.
|
|
4380
|
+
function readTextKeyValueAt(s: string, from: number): string | null | undefined {
|
|
4381
|
+
const afterKey = matchTextKey(s, from)
|
|
4382
|
+
if (afterKey === null) return undefined
|
|
4383
|
+
|
|
4384
|
+
const quote = s[afterKey]
|
|
4385
|
+
if (quote !== '"' && quote !== "'") return null
|
|
4386
|
+
return readStringValue(s, afterKey + 1, quote)
|
|
4387
|
+
}
|
|
4388
|
+
|
|
4389
|
+
// Returns the closing-quote index, or the last index when the literal is
|
|
4390
|
+
// truncated, so the caller's `i++` resumes past the consumed string.
|
|
4391
|
+
function skipStringLiteral(s: string, openIdx: number, quote: string): number {
|
|
4392
|
+
let escaped = false
|
|
4393
|
+
for (let i = openIdx + 1; i < s.length; i++) {
|
|
4394
|
+
const ch = s[i]!
|
|
4395
|
+
if (escaped) {
|
|
4396
|
+
escaped = false
|
|
4397
|
+
continue
|
|
4398
|
+
}
|
|
4399
|
+
if (ch === '\\') {
|
|
4400
|
+
escaped = true
|
|
4401
|
+
continue
|
|
4402
|
+
}
|
|
4403
|
+
if (ch === quote) return i
|
|
4404
|
+
}
|
|
4405
|
+
return s.length
|
|
4406
|
+
}
|
|
4407
|
+
|
|
4408
|
+
function matchTextKey(s: string, from: number): number | null {
|
|
4409
|
+
const m = /^\s*(?:"text"|'text'|text)\s*:\s*/.exec(s.slice(from))
|
|
4410
|
+
return m === null ? null : from + m[0].length
|
|
4249
4411
|
}
|
|
4250
4412
|
|
|
4413
|
+
function readStringValue(s: string, from: number, quote: string): string | null {
|
|
4414
|
+
let value = ''
|
|
4415
|
+
let escaped = false
|
|
4416
|
+
for (let i = from; i < s.length; i++) {
|
|
4417
|
+
const ch = s[i]!
|
|
4418
|
+
if (escaped) {
|
|
4419
|
+
value += ESCAPE_REPLACEMENTS[ch] ?? ch
|
|
4420
|
+
escaped = false
|
|
4421
|
+
continue
|
|
4422
|
+
}
|
|
4423
|
+
if (ch === '\\') {
|
|
4424
|
+
escaped = true
|
|
4425
|
+
continue
|
|
4426
|
+
}
|
|
4427
|
+
if (ch === quote) break
|
|
4428
|
+
value += ch
|
|
4429
|
+
}
|
|
4430
|
+
return value.trim().length > 0 ? value : null
|
|
4431
|
+
}
|
|
4432
|
+
|
|
4433
|
+
const ESCAPE_REPLACEMENTS: Record<string, string> = { n: '\n', r: '\r', t: '\t' }
|
|
4434
|
+
|
|
4251
4435
|
function describe(err: unknown): string {
|
|
4252
4436
|
return err instanceof Error ? err.message : String(err)
|
|
4253
4437
|
}
|
package/src/channels/schema.ts
CHANGED
|
@@ -107,11 +107,9 @@ const quotedReplySchema = z
|
|
|
107
107
|
.default({ enabled: true, queueDelayMs: DEFAULT_QUOTED_REPLY_QUEUE_DELAY_MS })
|
|
108
108
|
|
|
109
109
|
// Deliberately non-strict: a stale on-disk file may still carry the
|
|
110
|
-
// legacy `allow` field
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
// exactly what we want — a hard `.strict()` reject would brick recovery
|
|
114
|
-
// for any user mid-migration.
|
|
110
|
+
// legacy `allow` field. Zod silently drops unknown keys here, which is
|
|
111
|
+
// exactly what we want — the field is ignored, not translated, and a hard
|
|
112
|
+
// `.strict()` reject would brick recovery for any user with an old config.
|
|
115
113
|
const adapterSchema = z.object({
|
|
116
114
|
engagement: engagementSchema,
|
|
117
115
|
history: historySchema,
|
|
@@ -128,6 +126,7 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
|
|
|
128
126
|
'pull_request.ready_for_review',
|
|
129
127
|
'pull_request.review_requested',
|
|
130
128
|
'pull_request.review_request_removed',
|
|
129
|
+
'pull_request.synchronize',
|
|
131
130
|
'discussion.created',
|
|
132
131
|
'pull_request_review.submitted',
|
|
133
132
|
] as const
|
|
@@ -158,6 +157,21 @@ const GITHUB_EVENT_ALLOWLIST_V2 = [
|
|
|
158
157
|
'discussion.created',
|
|
159
158
|
'pull_request_review.submitted',
|
|
160
159
|
] as const
|
|
160
|
+
// - v3: added ready_for_review, shipped 0.12.0+ (the default just before
|
|
161
|
+
// synchronize was added). Snapshotted here so configs seeded with the
|
|
162
|
+
// pre-synchronize default unfreeze and re-track the new default.
|
|
163
|
+
const GITHUB_EVENT_ALLOWLIST_V3 = [
|
|
164
|
+
'issue_comment.created',
|
|
165
|
+
'pull_request_review_comment.created',
|
|
166
|
+
'discussion_comment.created',
|
|
167
|
+
'issues.opened',
|
|
168
|
+
'pull_request.opened',
|
|
169
|
+
'pull_request.ready_for_review',
|
|
170
|
+
'pull_request.review_requested',
|
|
171
|
+
'pull_request.review_request_removed',
|
|
172
|
+
'discussion.created',
|
|
173
|
+
'pull_request_review.submitted',
|
|
174
|
+
] as const
|
|
161
175
|
|
|
162
176
|
// Every event-allowlist that `channel add` / `init` has ever seeded verbatim
|
|
163
177
|
// into typeclaw.json, oldest first, current default last. The legacy-shape
|
|
@@ -169,6 +183,7 @@ const GITHUB_EVENT_ALLOWLIST_V2 = [
|
|
|
169
183
|
export const SEEDED_GITHUB_EVENT_ALLOWLISTS: readonly (readonly string[])[] = [
|
|
170
184
|
GITHUB_EVENT_ALLOWLIST_V1,
|
|
171
185
|
GITHUB_EVENT_ALLOWLIST_V2,
|
|
186
|
+
GITHUB_EVENT_ALLOWLIST_V3,
|
|
172
187
|
DEFAULT_GITHUB_EVENT_ALLOWLIST,
|
|
173
188
|
]
|
|
174
189
|
|
package/src/cli/channel.ts
CHANGED
|
@@ -629,8 +629,9 @@ async function promptGithubCredentials(cwd: string): Promise<{
|
|
|
629
629
|
message: 'GitHub authentication type',
|
|
630
630
|
options: [
|
|
631
631
|
{ value: 'pat', label: 'Fine-grained personal access token' },
|
|
632
|
-
{ value: 'app', label: 'GitHub App installation token' },
|
|
632
|
+
{ value: 'app', label: 'GitHub App installation token (recommended)' },
|
|
633
633
|
],
|
|
634
|
+
initialValue: 'app',
|
|
634
635
|
})
|
|
635
636
|
if (isCancel(authType)) {
|
|
636
637
|
cancel('Aborted.')
|
package/src/cli/init.ts
CHANGED
|
@@ -1182,8 +1182,9 @@ async function runGithubFlow(cwd: string): Promise<StepResult<CollectedInputs['c
|
|
|
1182
1182
|
message: 'GitHub authentication type',
|
|
1183
1183
|
options: [
|
|
1184
1184
|
{ value: 'pat', label: 'Fine-grained personal access token' },
|
|
1185
|
-
{ value: 'app', label: 'GitHub App installation token' },
|
|
1185
|
+
{ value: 'app', label: 'GitHub App installation token (recommended)' },
|
|
1186
1186
|
],
|
|
1187
|
+
initialValue: 'app',
|
|
1187
1188
|
})
|
|
1188
1189
|
if (isCancel(authType)) return back()
|
|
1189
1190
|
const auth = authType === 'pat' ? await promptGithubPatAuth() : await promptGithubAppAuth()
|
package/src/cli/inspect.ts
CHANGED
|
@@ -2,7 +2,19 @@ import { defineCommand } from 'citty'
|
|
|
2
2
|
|
|
3
3
|
import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
|
|
4
4
|
import { findAgentDir } from '@/init'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
listViewerItems,
|
|
7
|
+
openViewerItem,
|
|
8
|
+
parseDuration,
|
|
9
|
+
parseFilter,
|
|
10
|
+
resolveSession,
|
|
11
|
+
runInspectLoop,
|
|
12
|
+
runViewerLoop,
|
|
13
|
+
streamLive,
|
|
14
|
+
type LiveSourceFactory,
|
|
15
|
+
type SessionSummary,
|
|
16
|
+
type ViewerItem,
|
|
17
|
+
} from '@/inspect'
|
|
6
18
|
import { originLabel, shortSessionId } from '@/inspect/label'
|
|
7
19
|
|
|
8
20
|
import { createTailScope } from './inspect-controller'
|
|
@@ -13,12 +25,12 @@ const ESC_DEBOUNCE_MS = 50
|
|
|
13
25
|
export const inspectCommand = defineCommand({
|
|
14
26
|
meta: {
|
|
15
27
|
name: 'inspect',
|
|
16
|
-
description: '
|
|
28
|
+
description: 'session viewer: pick a session, the live TUI, or container logs to observe (host stage)',
|
|
17
29
|
},
|
|
18
30
|
args: {
|
|
19
31
|
session: {
|
|
20
32
|
type: 'positional',
|
|
21
|
-
description: 'session id or short prefix (omit to pick from
|
|
33
|
+
description: 'session id or short prefix (omit to pick from the list)',
|
|
22
34
|
required: false,
|
|
23
35
|
},
|
|
24
36
|
filter: {
|
|
@@ -42,42 +54,175 @@ export const inspectCommand = defineCommand({
|
|
|
42
54
|
const sessionArg = typeof args.session === 'string' ? args.session : undefined
|
|
43
55
|
const filterArg = typeof args.filter === 'string' ? args.filter : undefined
|
|
44
56
|
const sinceArg = typeof args.since === 'string' ? args.since : undefined
|
|
45
|
-
|
|
46
57
|
const isJson = args.json === true
|
|
47
|
-
const liveSource = isJson ? undefined : await buildLiveSource(cwd)
|
|
48
|
-
const interactive = !isJson && Boolean(process.stdin.isTTY)
|
|
49
|
-
const liveHint = interactive ? escHintLine(color) : undefined
|
|
50
|
-
|
|
51
|
-
const result = await runInspectLoop({
|
|
52
|
-
agentDir: cwd,
|
|
53
|
-
...(sessionArg !== undefined ? { sessionIdOrPrefix: sessionArg } : {}),
|
|
54
|
-
...(filterArg !== undefined ? { filter: filterArg } : {}),
|
|
55
|
-
...(sinceArg !== undefined ? { since: sinceArg } : {}),
|
|
56
|
-
json: isJson,
|
|
57
|
-
color,
|
|
58
|
-
selectSession: (sessions, selectOpts) => clackSelect(sessions, selectOpts?.initialSessionId),
|
|
59
|
-
...(liveSource !== undefined ? { liveSource } : {}),
|
|
60
|
-
createTailScope: () => createTailScope({ debounceMs: ESC_DEBOUNCE_MS }),
|
|
61
|
-
...(interactive ? { interactive: true } : {}),
|
|
62
|
-
...(liveHint !== undefined ? { liveHint } : {}),
|
|
63
|
-
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
64
|
-
stderr: (line) => process.stderr.write(`${line}\n`),
|
|
65
|
-
})
|
|
66
58
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
59
|
+
// JSON mode stays the scriptable, session-only path: no list, no logs/tui
|
|
60
|
+
// rows, explicit session id required. Behavior is unchanged from before the
|
|
61
|
+
// viewer merge.
|
|
62
|
+
if (isJson) {
|
|
63
|
+
const result = await runInspectLoop({
|
|
64
|
+
agentDir: cwd,
|
|
65
|
+
...(sessionArg !== undefined ? { sessionIdOrPrefix: sessionArg } : {}),
|
|
66
|
+
...(filterArg !== undefined ? { filter: filterArg } : {}),
|
|
67
|
+
...(sinceArg !== undefined ? { since: sinceArg } : {}),
|
|
68
|
+
json: true,
|
|
69
|
+
color,
|
|
70
|
+
selectSession: (sessions, selectOpts) => clackSelectSession(sessions, selectOpts?.initialSessionId),
|
|
71
|
+
createTailScope: () => createTailScope({ debounceMs: ESC_DEBOUNCE_MS }),
|
|
72
|
+
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
73
|
+
stderr: (line) => process.stderr.write(`${line}\n`),
|
|
74
|
+
})
|
|
75
|
+
finish(result)
|
|
76
|
+
return
|
|
70
77
|
}
|
|
71
|
-
|
|
78
|
+
|
|
79
|
+
const exitCode = await runInspectViewer({
|
|
80
|
+
cwd,
|
|
81
|
+
...(sessionArg !== undefined ? { sessionArg } : {}),
|
|
82
|
+
...(filterArg !== undefined ? { filterArg } : {}),
|
|
83
|
+
...(sinceArg !== undefined ? { sinceArg } : {}),
|
|
84
|
+
color,
|
|
85
|
+
})
|
|
86
|
+
process.exit(exitCode)
|
|
72
87
|
},
|
|
73
88
|
})
|
|
74
89
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
90
|
+
export type RunInspectViewerOptions = {
|
|
91
|
+
cwd: string
|
|
92
|
+
sessionArg?: string
|
|
93
|
+
filterArg?: string
|
|
94
|
+
sinceArg?: string
|
|
95
|
+
color?: boolean
|
|
96
|
+
// Set false by the `tui` detach handoff: the live session was just ended, so
|
|
97
|
+
// no row should be offered as writable (see listViewerItems).
|
|
98
|
+
allowWritable?: boolean
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The interactive session-viewer: list → open → back to list. Shared by the
|
|
102
|
+
// `inspect` command and `tui`'s esc-detach fallthrough. Returns an exit code
|
|
103
|
+
// instead of calling process.exit so callers can chain (e.g. tui drops here).
|
|
104
|
+
export async function runInspectViewer(opts: RunInspectViewerOptions): Promise<number> {
|
|
105
|
+
const { cwd } = opts
|
|
106
|
+
const color = opts.color ?? useColor()
|
|
107
|
+
|
|
108
|
+
const filterResult = parseFilter(opts.filterArg)
|
|
109
|
+
if (!filterResult.ok) {
|
|
110
|
+
process.stderr.write(`${errorLine(filterResult.reason)}\n`)
|
|
111
|
+
return 2
|
|
112
|
+
}
|
|
113
|
+
let sinceMs: number | undefined
|
|
114
|
+
if (opts.sinceArg !== undefined) {
|
|
115
|
+
const d = parseDuration(opts.sinceArg)
|
|
116
|
+
if (!d.ok) {
|
|
117
|
+
process.stderr.write(`${errorLine(d.reason)}\n`)
|
|
118
|
+
return 2
|
|
119
|
+
}
|
|
120
|
+
sinceMs = Date.now() - d.ms
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const containerRunning = (await requireContainerRunning({ cwd })).ok
|
|
124
|
+
if (!containerRunning) {
|
|
125
|
+
process.stderr.write(`${c.yellow('⚠')} container not running; showing read-only history and logs only\n`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const sessionsDir = `${cwd}/sessions`
|
|
129
|
+
|
|
130
|
+
// Resolve a session arg (id or short prefix) to a full session id BEFORE the
|
|
131
|
+
// loop: runViewerLoop matches preselectKey against exact itemKeys, so a bare
|
|
132
|
+
// prefix would otherwise miss every row and report "no sessions". 'logs' is a
|
|
133
|
+
// reserved key, not a session, so it bypasses resolution.
|
|
134
|
+
let preselectKey: string | undefined
|
|
135
|
+
if (opts.sessionArg !== undefined && opts.sessionArg !== 'logs') {
|
|
136
|
+
const resolved = await resolveSession(sessionsDir, opts.sessionArg, (l) => process.stderr.write(`${l}\n`))
|
|
137
|
+
if (!resolved.ok) {
|
|
138
|
+
const reason =
|
|
139
|
+
resolved.reason === 'ambiguous'
|
|
140
|
+
? `Ambiguous session prefix "${opts.sessionArg}" matches ${resolved.matches.length} sessions. Use a longer prefix or run \`typeclaw inspect\` without args.`
|
|
141
|
+
: `No session matching "${opts.sessionArg}" in ${sessionsDir}/`
|
|
142
|
+
process.stderr.write(`${errorLine(reason)}\n`)
|
|
143
|
+
return resolved.reason === 'ambiguous' ? 2 : 1
|
|
144
|
+
}
|
|
145
|
+
preselectKey = resolved.summary.sessionId
|
|
146
|
+
} else if (opts.sessionArg === 'logs') {
|
|
147
|
+
preselectKey = 'logs'
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const interactive = Boolean(process.stdin.isTTY)
|
|
151
|
+
const liveHint = interactive ? escHintLine(color) : undefined
|
|
152
|
+
const liveSource = containerRunning ? await buildLiveSource(cwd) : undefined
|
|
153
|
+
|
|
154
|
+
const stdout = (line: string): void => {
|
|
155
|
+
process.stdout.write(`${line}\n`)
|
|
156
|
+
}
|
|
157
|
+
const stderr = (line: string): void => {
|
|
158
|
+
process.stderr.write(`${line}\n`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const open = openViewerItem({
|
|
162
|
+
cwd,
|
|
163
|
+
filter: filterResult.filter,
|
|
164
|
+
sinceMs,
|
|
165
|
+
json: false,
|
|
166
|
+
color,
|
|
167
|
+
interactive,
|
|
168
|
+
stdout,
|
|
169
|
+
stderr,
|
|
170
|
+
resolveTuiUrl: () => resolveTuiUrl(cwd),
|
|
171
|
+
...(liveSource !== undefined ? { liveSource } : {}),
|
|
172
|
+
...(liveHint !== undefined ? { liveHint } : {}),
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
const cliAllowWritable = opts.allowWritable !== false
|
|
176
|
+
const result = await runViewerLoop<ViewerItem>({
|
|
177
|
+
listItems: async ({ allowWritable: loopAllowWritable }) => {
|
|
178
|
+
const listOpts: Parameters<typeof listViewerItems>[0] = {
|
|
179
|
+
sessionsDir,
|
|
180
|
+
containerRunning,
|
|
181
|
+
// Compose the CLI-level permission (false on tui detach handoff) with
|
|
182
|
+
// the loop-level one (false after returning to the picker from a viewer).
|
|
183
|
+
allowWritable: cliAllowWritable && loopAllowWritable,
|
|
184
|
+
limit: 20,
|
|
185
|
+
onWarn: stderr,
|
|
186
|
+
}
|
|
187
|
+
if (sinceMs !== undefined) listOpts.sinceMs = sinceMs
|
|
188
|
+
return (await listViewerItems(listOpts)).items
|
|
189
|
+
},
|
|
190
|
+
keyOf: (item) => (item.kind === 'logs' ? 'logs' : item.summary.sessionId),
|
|
191
|
+
...(preselectKey !== undefined ? { preselectKey } : {}),
|
|
192
|
+
selectItem: (items, selectOpts) => clackSelectItem(items, selectOpts.initialKey),
|
|
193
|
+
openItem: open,
|
|
194
|
+
createTailScope: () => createTailScope({ debounceMs: ESC_DEBOUNCE_MS }),
|
|
195
|
+
onEmpty: () => ({
|
|
196
|
+
ok: false,
|
|
197
|
+
exitCode: 1,
|
|
198
|
+
reason: `No sessions found in ${sessionsDir}/.\nStart a session with \`typeclaw tui\` or send a message from a configured channel.`,
|
|
199
|
+
}),
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
if (!result.ok && result.reason !== undefined) {
|
|
203
|
+
process.stderr.write(`${errorLine(result.reason)}\n`)
|
|
204
|
+
}
|
|
205
|
+
return result.exitCode
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function finish(result: { ok: boolean; exitCode: number; reason?: string }): void {
|
|
209
|
+
if (!result.ok && result.reason !== undefined) {
|
|
210
|
+
process.stderr.write(`${errorLine(result.reason)}\n`)
|
|
80
211
|
}
|
|
212
|
+
process.exit(result.exitCode)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function resolveTuiUrl(cwd: string): Promise<string> {
|
|
216
|
+
const precheck = await requireContainerRunning({ cwd })
|
|
217
|
+
if (!precheck.ok) throw new Error(precheck.reason)
|
|
218
|
+
const port = await resolveHostPort({ cwd })
|
|
219
|
+
const token = await resolveTuiToken({ cwd })
|
|
220
|
+
const url = new URL(`ws://127.0.0.1:${port}`)
|
|
221
|
+
if (token !== null) url.searchParams.set('token', token)
|
|
222
|
+
return url.toString()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefined> {
|
|
81
226
|
const port = await resolveHostPort({ cwd })
|
|
82
227
|
const token = await resolveTuiToken({ cwd })
|
|
83
228
|
const baseUrl = new URL(`ws://127.0.0.1:${port}/inspect`)
|
|
@@ -94,7 +239,7 @@ async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefin
|
|
|
94
239
|
}
|
|
95
240
|
|
|
96
241
|
function escHintLine(color: boolean): string {
|
|
97
|
-
const text = '(esc to return to
|
|
242
|
+
const text = '(esc to return to the list · q to quit)'
|
|
98
243
|
return color ? `\u001b[2m${text}\u001b[0m` : text
|
|
99
244
|
}
|
|
100
245
|
|
|
@@ -105,7 +250,29 @@ function useColor(): boolean {
|
|
|
105
250
|
return Boolean(process.stdout.isTTY)
|
|
106
251
|
}
|
|
107
252
|
|
|
108
|
-
async function
|
|
253
|
+
async function clackSelectItem(items: ViewerItem[], initialKey: string | undefined): Promise<ViewerItem | null> {
|
|
254
|
+
const { select } = await import('@clack/prompts')
|
|
255
|
+
prepareStdinForClack()
|
|
256
|
+
const keyOf = (item: ViewerItem): string => (item.kind === 'logs' ? 'logs' : item.summary.sessionId)
|
|
257
|
+
const preferred =
|
|
258
|
+
initialKey !== undefined && items.some((i) => keyOf(i) === initialKey) ? initialKey : keyOf(items[0]!)
|
|
259
|
+
const picked = await select<string>({
|
|
260
|
+
message: `Pick what to view (showing ${items.length})`,
|
|
261
|
+
options: items.map((item) => ({
|
|
262
|
+
value: keyOf(item),
|
|
263
|
+
label: itemLabel(item),
|
|
264
|
+
...itemHint(item),
|
|
265
|
+
})),
|
|
266
|
+
initialValue: preferred,
|
|
267
|
+
})
|
|
268
|
+
if (isCancel(picked)) {
|
|
269
|
+
cancel('Cancelled.')
|
|
270
|
+
return null
|
|
271
|
+
}
|
|
272
|
+
return items.find((i) => keyOf(i) === picked) ?? null
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function clackSelectSession(
|
|
109
276
|
sessions: SessionSummary[],
|
|
110
277
|
initialSessionId: string | undefined,
|
|
111
278
|
): Promise<SessionSummary | null> {
|
|
@@ -119,7 +286,7 @@ async function clackSelect(
|
|
|
119
286
|
message: `Pick a session to inspect (showing ${sessions.length})`,
|
|
120
287
|
options: sessions.map((s) => ({
|
|
121
288
|
value: s.sessionId,
|
|
122
|
-
label:
|
|
289
|
+
label: sessionRowLabel(s),
|
|
123
290
|
...(s.firstPrompt !== null ? { hint: truncate(s.firstPrompt, 60) } : { hint: '(no prompt)' }),
|
|
124
291
|
})),
|
|
125
292
|
initialValue: preferred,
|
|
@@ -131,7 +298,20 @@ async function clackSelect(
|
|
|
131
298
|
return sessions.find((s) => s.sessionId === picked) ?? null
|
|
132
299
|
}
|
|
133
300
|
|
|
134
|
-
function
|
|
301
|
+
function itemLabel(item: ViewerItem): string {
|
|
302
|
+
if (item.kind === 'logs') return `${c.dim('▤')} container logs`
|
|
303
|
+
if (item.kind === 'tui') return `${c.green('●')} ${c.bold('live TUI')} ${sessionRowLabel(item.summary)}`
|
|
304
|
+
return `${c.dim('○')} ${sessionRowLabel(item.summary)}`
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function itemHint(item: ViewerItem): { hint: string } {
|
|
308
|
+
if (item.kind === 'logs') return { hint: 'read-only · works offline' }
|
|
309
|
+
if (item.kind === 'tui') return { hint: 'read+write · esc detaches and ends the live session' }
|
|
310
|
+
if (item.summary.firstPrompt !== null) return { hint: truncate(item.summary.firstPrompt, 60) }
|
|
311
|
+
return { hint: '(no prompt)' }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function sessionRowLabel(s: SessionSummary): string {
|
|
135
315
|
const id = shortSessionId(s.sessionId)
|
|
136
316
|
const label = s.origin === null ? '(unknown origin)' : originLabel(s.origin)
|
|
137
317
|
const when = formatRelative(s.mtimeMs)
|
package/src/cli/logs.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { defineCommand } from 'citty'
|
|
|
3
3
|
import { logs, parseTailValue } from '@/container'
|
|
4
4
|
import { findAgentDir } from '@/init'
|
|
5
5
|
|
|
6
|
+
import { runInspectViewer } from './inspect'
|
|
6
7
|
import { c, errorLine } from './ui'
|
|
7
8
|
|
|
8
9
|
export const logsCommand = defineCommand({
|
|
@@ -22,6 +23,11 @@ export const logsCommand = defineCommand({
|
|
|
22
23
|
alias: 'n',
|
|
23
24
|
description: 'number of lines to show from the end of the logs (non-negative integer or "all")',
|
|
24
25
|
},
|
|
26
|
+
list: {
|
|
27
|
+
type: 'boolean',
|
|
28
|
+
description: 'open the session viewer on the logs entry instead of dumping logs',
|
|
29
|
+
default: false,
|
|
30
|
+
},
|
|
25
31
|
},
|
|
26
32
|
async run({ args }) {
|
|
27
33
|
const cwd = findAgentDir(process.cwd()) ?? process.cwd()
|
|
@@ -36,6 +42,15 @@ export const logsCommand = defineCommand({
|
|
|
36
42
|
tail = parsed.value
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
// The viewer is strictly opt-in via --list, so the default `typeclaw logs`
|
|
46
|
+
// (piped, redirected, -f, or a plain TTY dump) keeps the raw `docker logs`
|
|
47
|
+
// pump that `typeclaw logs | grep` and CI depend on. --list drops into the
|
|
48
|
+
// session viewer pre-opened on the logs entry, where esc returns to the list.
|
|
49
|
+
if (args.list) {
|
|
50
|
+
const exitCode = await runInspectViewer({ cwd, sessionArg: 'logs' })
|
|
51
|
+
process.exit(exitCode)
|
|
52
|
+
}
|
|
53
|
+
|
|
39
54
|
if (args.follow) {
|
|
40
55
|
console.log(c.cyan('Streaming container logs...'))
|
|
41
56
|
} else {
|