typeclaw 0.15.0 → 0.15.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -151,19 +151,20 @@ TypeClaw runtime version: ${version}.`
151
151
  // would already be re-billed on each turn's user message — so this is
152
152
  // cache-free relative to the previous "## Now" placement.
153
153
  //
154
- // The block emits both English and Korean weekday names alongside the ISO
155
- // timestamp because models replying in a non-English language frequently
156
- // compute weekday-from-ISO incorrectly; pre-computing the weekday in both
157
- // candidate reply languages removes that arithmetic step entirely. The
158
- // framing is a single `<current-time>` XML tag for parity with other
159
- // runtime-injected per-turn blocks the agent already sees
160
- // (`<system-reminder>` etc.), so the model reads it as a structured anchor
161
- // rather than as content authored by a human in the chat.
154
+ // The block emits the English weekday name alongside the ISO timestamp
155
+ // because models frequently compute weekday-from-ISO incorrectly;
156
+ // pre-computing it removes that arithmetic step entirely. English only:
157
+ // TypeClaw's users are global, so the anchor uses one canonical language
158
+ // and leaves reply language to each agent's SOUL.md. The framing is a
159
+ // single `<current-time>` XML tag for parity with other runtime-injected
160
+ // per-turn blocks the agent already sees (`<system-reminder>` etc.), so
161
+ // the model reads it as a structured anchor rather than as content
162
+ // authored by a human in the chat.
162
163
  export function renderTurnTimeAnchor(now: Date = new Date()): string {
163
164
  const iso = formatLocalDateTime(now)
164
165
  const zone = resolveLocalTimezoneName()
165
166
  const weekday = formatLocalWeekday(now)
166
- return `<current-time>${iso} (${zone}, ${weekday.en} / ${weekday.ko})</current-time>`
167
+ return `<current-time>${iso} (${zone}, ${weekday})</current-time>`
167
168
  }
168
169
 
169
170
  // Compact replacement for DEFAULT_SYSTEM_PROMPT, used by non-interactive
@@ -10,7 +10,7 @@ import {
10
10
  import type { AdapterId } from '@/channels/schema'
11
11
 
12
12
  import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
13
- import { fenceRuntimeNotice } from './runtime-notice'
13
+ import { fenceRuntimeNotice, fenceToolResult } from './runtime-notice'
14
14
 
15
15
  export type ChannelReplyOrigin = {
16
16
  adapter: AdapterId
@@ -71,11 +71,20 @@ export function createChannelReplyTool({
71
71
  },
72
72
  ),
73
73
  ),
74
+ continue: Type.Optional(
75
+ Type.Boolean({
76
+ description:
77
+ 'Set `true` ONLY when this reply is a mid-turn status update (e.g. "working on it…") and you still have work to do THIS turn — fetching data, running a tool, spawning a subagent, then replying again. ' +
78
+ 'A normal reply omits this: by default a successful reply ends the turn (no wasted follow-up LLM call). ' +
79
+ 'Do not set it just to seem responsive; only when genuine multi-step work follows in the same turn.',
80
+ }),
81
+ ),
74
82
  }),
75
83
 
76
84
  async execute(_toolCallId, params) {
77
85
  const text = params.text
78
86
  const attachments = params.attachments
87
+ const keepTurnAlive = params.continue === true
79
88
  if ((text === undefined || text === '') && (attachments === undefined || attachments.length === 0)) {
80
89
  logger.warn(formatChannelToolFailure('channel_reply', 'missing text and attachments'))
81
90
  return {
@@ -130,7 +139,14 @@ export function createChannelReplyTool({
130
139
  ),
131
140
  )
132
141
  }
133
- const details: { ok: boolean; error?: string } = result.ok ? { ok: true } : { ok: false, error: result.error }
142
+ // `continue` is read by the router's terminal hook (installChannelReplyTerminalHook),
143
+ // not by this tool — it suppresses the post-reply abort so a multi-step turn
144
+ // keeps going. Success-only: a denied reply never ran, so there is no turn to keep.
145
+ const details: { ok: boolean; error?: string; continue?: boolean } = result.ok
146
+ ? keepTurnAlive
147
+ ? { ok: true, continue: true }
148
+ : { ok: true }
149
+ : { ok: false, error: result.error }
134
150
  // Echo the delivered text back to the model. The adapter classifier
135
151
  // drops self-authored messages on the inbound path (`self_author`),
136
152
  // so the bot otherwise has ZERO visibility into what it just said —
@@ -138,34 +154,37 @@ export function createChannelReplyTool({
138
154
  // Without this echo, a model that splits a multi-part reply has no
139
155
  // way to tell "did I already send part 1?" from "I haven't started
140
156
  // yet", and routinely re-sends near-duplicates within the same turn
141
- // (observed in production: two consecutive identical
142
- // greeting messages to one prompt).
157
+ // (observed in production: two consecutive identical greeting messages
158
+ // to one prompt).
143
159
  //
144
- // We deliberately do NOT cap sends-per-turn here. A complex user
145
- // request legitimately needs split replies, and a hard cap would
146
- // mutilate that. The fix is to give the model honest feedback —
147
- // show it what it sent, let it decide whether to continue.
148
- // Truncate past 500 chars so a long reply doesn't double the prompt
149
- // size on every subsequent iteration; the prefix is enough to detect
150
- // duplication, and the full text is recoverable from the session
151
- // JSONL if needed.
152
- const echo = renderOutboundEcho(text, attachments)
153
- const baseText = result.ok
154
- ? `posted to ${origin.adapter}:${origin.workspace}/${origin.chat}: ${echo}`
155
- : `channel_reply denied: ${result.error}`
156
- const hint = result.ok
157
- ? consecutiveSendHint(
158
- router.getConsecutiveSendCount({
159
- adapter: origin.adapter,
160
- workspace: origin.workspace,
161
- chat: origin.chat,
162
- thread: origin.thread,
163
- }),
164
- )
165
- : ''
166
- const body = hint ? `${baseText}${hint}` : baseText
160
+ // The echo is the model's OWN words, which is uniquely seductive to
161
+ // "reply" to, so on the success path we wrap the whole result in the
162
+ // strong SYSTEM MESSAGE fence (`fenceToolResult`) rather than the weak
163
+ // `[system: tool result...]` prefix the prefix did not stop Kimi from
164
+ // answering its own echo and looping (PR #481). Denials carry no echoed
165
+ // prose (just machine error text), so they keep the lighter prefix.
166
+ if (result.ok) {
167
+ const echo = renderOutboundEcho(text, attachments)
168
+ const receipt = `posted to ${origin.adapter}:${origin.workspace}/${origin.chat}: ${echo}`
169
+ const hint = consecutiveSendHint(
170
+ router.getConsecutiveSendCount({
171
+ adapter: origin.adapter,
172
+ workspace: origin.workspace,
173
+ chat: origin.chat,
174
+ thread: origin.thread,
175
+ }),
176
+ )
177
+ // Keep fenceToolResult here — do NOT "unify" the success branch back to
178
+ // TOOL_RESULT_PREFIX to match the denial branch below. The prefix is
179
+ // intentionally weaker and is safe ONLY because denials carry no echoed
180
+ // prose; the success result does, and the weak prefix let Kimi loop.
181
+ return {
182
+ content: [{ type: 'text' as const, text: `${fenceToolResult(receipt)}${hint}` }],
183
+ details,
184
+ }
185
+ }
167
186
  return {
168
- content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}${body}` }],
187
+ content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}channel_reply denied: ${result.error}` }],
169
188
  details,
170
189
  }
171
190
  },
@@ -188,6 +207,13 @@ export function renderEcho(text: string): string {
188
207
  return `${JSON.stringify(text.slice(0, ECHO_MAX_CHARS))}... (${text.length} chars total)`
189
208
  }
190
209
 
210
+ // DO NOT remove this echo or replace it with a hash/length-only "receipt" to
211
+ // stop the self-reply loop (PR #481). That trade was tried and rejected: the
212
+ // echo is the model's only view of what it already said (the inbound path
213
+ // drops self-authored messages), so without the FULL text a split reply
214
+ // re-sends near-duplicates — the exact bug 58c62c1 added the echo to fix, and
215
+ // a fingerprint cannot catch paraphrased near-dupes. The loop is solved by
216
+ // FENCING this echo (see fenceToolResult call site below), not by removing it.
191
217
  export function renderOutboundEcho(
192
218
  text: string | undefined,
193
219
  attachments: ReadonlyArray<{ path: string; filename?: string }> | undefined,
@@ -11,7 +11,7 @@ import { ADAPTER_IDS, type AdapterId } from '@/channels/schema'
11
11
 
12
12
  import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
13
13
  import { renderOutboundEcho, TOOL_RESULT_PREFIX } from './channel-reply'
14
- import { fenceRuntimeNotice } from './runtime-notice'
14
+ import { fenceRuntimeNotice, fenceToolResult } from './runtime-notice'
15
15
 
16
16
  export type ChannelSendOrigin = {
17
17
  adapter: AdapterId
@@ -154,12 +154,13 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
154
154
  )
155
155
  }
156
156
  const details: { ok: boolean; error?: string } = result.ok ? { ok: true } : { ok: false, error: result.error }
157
- const echo = renderOutboundEcho(bodyText, attachments)
158
- const baseText = result.ok
159
- ? `posted to ${params.adapter}:${params.workspace}/${params.chat}: ${echo}`
160
- : `channel_send denied: ${result.error}`
161
- const hints: string[] = []
157
+ // Success wraps the echoed sent text in the strong SYSTEM MESSAGE fence;
158
+ // denials keep the lighter prefix. See channel-reply.ts for the full
159
+ // rationale (PR #481 self-reply loop).
162
160
  if (result.ok) {
161
+ const echo = renderOutboundEcho(bodyText, attachments)
162
+ const receipt = `posted to ${params.adapter}:${params.workspace}/${params.chat}: ${echo}`
163
+ const hints: string[] = []
163
164
  const consecutive = consecutiveSendHint(
164
165
  router.getConsecutiveSendCount({
165
166
  adapter,
@@ -177,10 +178,14 @@ export function createChannelSendTool({ router, origin, logger = consoleChannelL
177
178
  thread: params.thread,
178
179
  })
179
180
  if (threadMismatch) hints.push(threadMismatch)
181
+
182
+ return {
183
+ content: [{ type: 'text' as const, text: `${fenceToolResult(receipt)}${hints.join('')}` }],
184
+ details,
185
+ }
180
186
  }
181
- const body = hints.length > 0 ? `${baseText}${hints.join('')}` : baseText
182
187
  return {
183
- content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}${body}` }],
188
+ content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}channel_send denied: ${result.error}` }],
184
189
  details,
185
190
  }
186
191
  },
@@ -39,3 +39,31 @@ export function fenceRuntimeNotice(body: string): string {
39
39
  '---'
40
40
  )
41
41
  }
42
+
43
+ // Wraps a channel tool result (delivery confirmation + echoed sent text) in the
44
+ // SAME canonical SYSTEM MESSAGE framing as fenceRuntimeNotice — but as the
45
+ // ENTIRE result body, not an appended hint, so there is no unfenced prose for
46
+ // the model to read as conversation.
47
+ //
48
+ // The echoed sent text is load-bearing (the bot has no other view of what it
49
+ // just said — the inbound path drops self-authored messages — so without it a
50
+ // split reply re-sends near-duplicates). But that text is the model's OWN
51
+ // words, which is uniquely seductive to "reply" to: a persona-rich model
52
+ // (Kimi K2 on the GitHub channel, PR #481) read its own delivered prose as a
53
+ // fresh user turn and answered it ("you're welcome!", "aww thanks!") until the
54
+ // per-turn send cap. The weak `[system: tool result...]` prefix did not stop
55
+ // the misread; the full fence — bracketed marker, horizontal-rule fences,
56
+ // explicit "Do not reply" closer — has months of production track record
57
+ // against Kimi (it already wraps the consecutive-send and thread-mismatch
58
+ // hints). Reusing the exact same shape extends that protection to the echo.
59
+ export function fenceToolResult(receipt: string): string {
60
+ return (
61
+ '---\n' +
62
+ '**[SYSTEM MESSAGE — not from a human]**\n\n' +
63
+ receipt +
64
+ '\n\nThe text above is your OWN already-delivered message, echoed back so ' +
65
+ 'you can see what you sent — it is NOT a new message from anyone in the ' +
66
+ 'chat. **Do not acknowledge or reply to it.**\n' +
67
+ '---'
68
+ )
69
+ }
@@ -24,6 +24,7 @@ export const webfetchTool = defineTool({
24
24
  description:
25
25
  'Fetch a single HTTP(S) URL and return the body, optionally compacted by a strategy. ' +
26
26
  'Use this when the user references a specific URL or when websearch surfaced a result you need to read in full. ' +
27
+ 'If `spawn_subagent` is available to you, PREFER delegating to the `scout` subagent by default: spawn it whenever you expect more than one fetch, an "across multiple sources" task, or any search-then-fetch loop. Scout runs the noisy fetching in its own context window and returns a distilled, citation-backed answer, keeping bulky page bodies out of yours. Only call this tool directly for a single known URL whose content you will cite immediately — or whenever you cannot spawn subagents (e.g. you are yourself a subagent), in which case fetch here. ' +
27
28
  'Outbound requests impersonate Chrome 136 at the TLS, HTTP/2, and header layers ' +
28
29
  '(via curl-impersonate), which helps with TLS/header fingerprint gates on sites behind Cloudflare/Akamai. ' +
29
30
  'It does NOT solve JavaScript challenges, behavioural fingerprinting (mouse/scroll/timing), interactive CAPTCHAs, ' +
@@ -20,7 +20,8 @@ export const websearchTool = defineTool({
20
20
  name: 'websearch',
21
21
  label: 'Web Search',
22
22
  description:
23
- 'Search the public web. Returns a ranked list of {title, url, snippet} entries. Use `source: "wikipedia"` for encyclopedic lookups; otherwise default to general web results from DuckDuckGo. Pair this with the `read` tool by visiting URLs you find with `bash` (curl) when you need full page contents.',
23
+ 'Search the public web. Returns a ranked list of {title, url, snippet} entries. Use `source: "wikipedia"` for encyclopedic lookups; otherwise default to general web results from DuckDuckGo. Pair this with the `read` tool by visiting URLs you find with `bash` (curl) when you need full page contents.\n' +
24
+ 'If `spawn_subagent` is available to you, PREFER delegating to the `scout` subagent by default: spawn it whenever the research is non-trivial (more than 1-2 queries, any "across multiple sources" framing, or follow-up fetches of the results). Scout runs `websearch`/`webfetch` in its own context window and returns a distilled, citation-backed answer, so the search churn never pollutes yours. Only call this tool directly for a single query whose top result you will cite immediately — or whenever you cannot spawn subagents (e.g. you are yourself a subagent), in which case run the searches here.',
24
25
  parameters: Type.Object({
25
26
  query: Type.String({ description: 'The search query.' }),
26
27
  limit: Type.Optional(
@@ -117,6 +117,18 @@ export const MAX_CHANNEL_SENDS_PER_TURN = 10
117
117
  // same-tick duplicate/cap denials) is never mistaken for a loop. Reset at turn
118
118
  // start alongside `turnSeq`.
119
119
  export const MAX_POLICY_DENIED_CHANNEL_SENDS_PER_TURN = 3
120
+ // Per-request output-token cap for channel sessions, threaded into the agent's
121
+ // stream options to override pi-ai's silent `Math.min(model.maxTokens, 32000)`
122
+ // default (`buildBaseOptions` in @mariozechner/pi-ai). Without it, Fireworks'
123
+ // kimi-k2p6-turbo — which degenerates into single-token repetition on the
124
+ // post-tool follow-up turn — runs the full 32000 tokens (~116s of garbage that
125
+ // never produces a reply) before `stopReason: 'length'`. The terminal-reply
126
+ // hook below removes the turn that triggers this; the cap bounds any other path
127
+ // that still reaches a channel LLM call. 4096 fits a thinking block plus a
128
+ // nontrivial reply (healthy channel turns observed at ~317 output tokens
129
+ // including reasoning). Deliberately NOT lowered in `providers.ts`, where
130
+ // `maxTokens` is the model's true capability that compaction math reads.
131
+ export const CHANNEL_MAX_OUTPUT_TOKENS = 4096
120
132
  // Rolling window for outbound send-rate telemetry. 5s matches Discord's
121
133
  // rate-limit shape (5 msg / 5 s / channel) and comfortably covers Slack's
122
134
  // 1 msg/s sustained. The window is observational; exceeding the burst
@@ -1059,6 +1071,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1059
1071
  logger.error(`[channels] ${live.keyId}: LLM call failed: ${err.message}`)
1060
1072
  })
1061
1073
  live.unsubTypingActivity = subscribeTypingActivity(created.session, live)
1074
+ installChannelReplyTerminalHook(live)
1075
+ installChannelOutputCap(live)
1062
1076
  liveSessions.set(keyId, live)
1063
1077
 
1064
1078
  if (isColdStart) {
@@ -1216,6 +1230,60 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1216
1230
  })
1217
1231
  }
1218
1232
 
1233
+ // After a successful `channel_reply`, the model has delivered its user-facing
1234
+ // response and the turn is semantically done. pi-agent-core's loop, however,
1235
+ // unconditionally makes one more LLM call after any tool result (the
1236
+ // "post-tool follow-up") to let multi-step tool chains continue. On a turn
1237
+ // that ended with `channel_reply` there is nothing left to say, and Fireworks'
1238
+ // kimi-k2p6-turbo degenerates that empty follow-up into a 32000-token
1239
+ // repetition loop (see CHANNEL_MAX_OUTPUT_TOKENS). Aborting the run's signal
1240
+ // from `afterToolCall` — which runs during tool execution, before the loop
1241
+ // re-enters the LLM stream — makes the follow-up stream observe an already-
1242
+ // aborted signal and return `stopReason: 'aborted'` without generating. This
1243
+ // is the same `agent.abort()` lever the policy-denied-send cap uses; the
1244
+ // tool's own result is already persisted, so the reply still lands.
1245
+ //
1246
+ // Scope is deliberately narrow: only `channel_reply` (the current-chat user-
1247
+ // facing response), only on success, and only for channel sessions. Read-only
1248
+ // tools and `channel_send` must keep the follow-up so genuine multi-step turns
1249
+ // continue. A prior non-typeclaw `afterToolCall` (none today) would be
1250
+ // composed, not clobbered.
1251
+ //
1252
+ // `channel_reply({ continue: true })` is the explicit opt-out: a mid-turn
1253
+ // status reply ("working on it…") that the model follows with more work this
1254
+ // turn. The tool surfaces that intent as `details.continue === true`, and we
1255
+ // keep the follow-up so the turn proceeds. The kimi 32k loop only recurs when
1256
+ // the model genuinely has nothing left to say after a reply, which `continue`
1257
+ // asserts is not the case; Layer 2's maxTokens cap still bounds any misuse.
1258
+ const installChannelReplyTerminalHook = (live: LiveSession): void => {
1259
+ const { agent } = live.session
1260
+ const prior = agent.afterToolCall
1261
+ agent.afterToolCall = async (context, signal) => {
1262
+ const result = prior ? await prior(context, signal) : undefined
1263
+ const details = context.result.details as { ok?: unknown; continue?: unknown } | undefined
1264
+ const succeeded = context.toolCall.name === 'channel_reply' && !context.isError && details?.ok === true
1265
+ const keepTurnAlive = details?.continue === true
1266
+ if (succeeded && !keepTurnAlive && agent.signal?.aborted !== true) {
1267
+ logger.info(`[channels] ${live.keyId} terminal_after_channel_reply`)
1268
+ agent.abort()
1269
+ }
1270
+ return result
1271
+ }
1272
+ }
1273
+
1274
+ // Override pi-ai's hidden `Math.min(model.maxTokens, 32000)` output cap for
1275
+ // channel sessions by threading an explicit `maxTokens` into every stream
1276
+ // call. See CHANNEL_MAX_OUTPUT_TOKENS for why. Composes the existing streamFn
1277
+ // (pi's default `streamSimple` unless a proxy was installed) and only fills
1278
+ // `maxTokens` when the caller left it unset, so an explicit per-call value
1279
+ // still wins.
1280
+ const installChannelOutputCap = (live: LiveSession): void => {
1281
+ const { agent } = live.session
1282
+ const inner = agent.streamFn
1283
+ agent.streamFn = (model, context, options) =>
1284
+ inner(model, context, { ...options, maxTokens: options?.maxTokens ?? CHANNEL_MAX_OUTPUT_TOKENS })
1285
+ }
1286
+
1219
1287
  const startTypingHeartbeat = (live: LiveSession): void => {
1220
1288
  if (live.typingTimedOut || live.typingStopPromise) return
1221
1289
  if (live.destroyed) return
@@ -1461,13 +1529,21 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1461
1529
  }, wait)
1462
1530
  }
1463
1531
 
1464
- const publishInbound = (event: InboundMessage, decision: 'engage' | 'observe' | 'denied' | 'claim'): void => {
1532
+ const publishInbound = (
1533
+ event: InboundMessage,
1534
+ decision: 'engage' | 'observe' | 'denied' | 'claim',
1535
+ // Undefined before a session exists (denied/claim intercepts). Carried so a
1536
+ // session-scoped `typeclaw inspect` only sees its own session's inbounds —
1537
+ // the broadcast otherwise fans out to every inspect client.
1538
+ sessionId?: string,
1539
+ ): void => {
1465
1540
  if (stream === undefined) return
1466
1541
  try {
1467
1542
  stream.publish({
1468
1543
  target: { kind: 'broadcast' },
1469
1544
  payload: {
1470
1545
  kind: 'channel-inbound',
1546
+ ...(sessionId !== undefined ? { sessionId } : {}),
1471
1547
  adapter: event.adapter,
1472
1548
  workspace: event.workspace,
1473
1549
  chat: event.chat,
@@ -1604,7 +1680,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1604
1680
  })
1605
1681
 
1606
1682
  if (decision === 'observe') {
1607
- publishInbound(event, 'observe')
1683
+ publishInbound(event, 'observe', live.sessionId)
1608
1684
  // Log every observe so an unanswered mention is diagnosable from logs
1609
1685
  // alone instead of "routed but no prompting" silence. The bracketed
1610
1686
  // shape mirrors `prompting batch=` so log scraping can pair them.
@@ -1613,7 +1689,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1613
1689
  return
1614
1690
  }
1615
1691
 
1616
- publishInbound(event, 'engage')
1692
+ publishInbound(event, 'engage', live.sessionId)
1617
1693
 
1618
1694
  updateLoopGuard(live, event)
1619
1695
 
@@ -49,31 +49,35 @@ export const inspectCommand = defineCommand({
49
49
  const escListener = isJson ? null : createEscListener()
50
50
  const liveHint = escListener === null ? undefined : escHintLine(color)
51
51
 
52
- const result = await runInspectLoop({
53
- agentDir: cwd,
54
- ...(sessionArg !== undefined ? { sessionIdOrPrefix: sessionArg } : {}),
55
- ...(filterArg !== undefined ? { filter: filterArg } : {}),
56
- ...(sinceArg !== undefined ? { since: sinceArg } : {}),
57
- json: isJson,
58
- color,
59
- selectSession: (sessions, selectOpts) => {
60
- escListener?.pause()
61
- return clackSelect(sessions, selectOpts?.initialSessionId).finally(() => {
62
- escListener?.resume()
63
- })
64
- },
65
- ...(liveSource !== undefined ? { liveSource } : {}),
66
- signal,
67
- newEscSignal: () => {
68
- if (escListener === null) return new AbortController().signal
69
- return escListener.armForStream()
70
- },
71
- ...(liveHint !== undefined ? { liveHint } : {}),
72
- stdout: (line) => process.stdout.write(`${line}\n`),
73
- stderr: (line) => process.stderr.write(`${line}\n`),
74
- })
75
-
76
- escListener?.stop()
52
+ // try/finally so a thrown loop never leaves the terminal stuck in raw mode.
53
+ let result: Awaited<ReturnType<typeof runInspectLoop>>
54
+ try {
55
+ result = await runInspectLoop({
56
+ agentDir: cwd,
57
+ ...(sessionArg !== undefined ? { sessionIdOrPrefix: sessionArg } : {}),
58
+ ...(filterArg !== undefined ? { filter: filterArg } : {}),
59
+ ...(sinceArg !== undefined ? { since: sinceArg } : {}),
60
+ json: isJson,
61
+ color,
62
+ selectSession: (sessions, selectOpts) => {
63
+ escListener?.pause()
64
+ return clackSelect(sessions, selectOpts?.initialSessionId).finally(() => {
65
+ escListener?.resume()
66
+ })
67
+ },
68
+ ...(liveSource !== undefined ? { liveSource } : {}),
69
+ signal,
70
+ newEscSignal: () => {
71
+ if (escListener === null) return new AbortController().signal
72
+ return escListener.armForStream()
73
+ },
74
+ ...(liveHint !== undefined ? { liveHint } : {}),
75
+ stdout: (line) => process.stdout.write(`${line}\n`),
76
+ stderr: (line) => process.stderr.write(`${line}\n`),
77
+ })
78
+ } finally {
79
+ escListener?.stop()
80
+ }
77
81
 
78
82
  if (!result.ok) {
79
83
  process.stderr.write(`${errorLine(result.reason)}\n`)
@@ -63,9 +63,17 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
63
63
  }
64
64
  })
65
65
 
66
- const onOpen = new Promise<void>((resolve, reject) => {
67
- ws.addEventListener('open', () => resolve(), { once: true })
66
+ // Settle on open OR on any terminal condition (error/close/abort). Resolving
67
+ // false here is what unblocks the connect gate when esc aborts mid-connect —
68
+ // otherwise `await onOpen` would hang forever and freeze the inspect CLI.
69
+ const onOpen = new Promise<boolean>((resolve, reject) => {
70
+ ws.addEventListener('open', () => resolve(true), { once: true })
68
71
  ws.addEventListener('error', () => reject(new Error('websocket connection failed')), { once: true })
72
+ ws.addEventListener('close', () => resolve(false), { once: true })
73
+ if (opts.signal !== undefined) {
74
+ if (opts.signal.aborted) resolve(false)
75
+ else opts.signal.addEventListener('abort', () => resolve(false), { once: true })
76
+ }
69
77
  })
70
78
  ws.addEventListener('close', () => {
71
79
  closed = true
@@ -96,12 +104,14 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
96
104
  }
97
105
  }
98
106
 
107
+ let opened: boolean
99
108
  try {
100
- await onOpen
109
+ opened = await onOpen
101
110
  } catch (err) {
102
111
  closed = true
103
112
  throw err
104
113
  }
114
+ if (!opened || closed || opts.signal?.aborted === true) return
105
115
 
106
116
  const subscribe: InspectClientMessage = {
107
117
  type: 'subscribe',
@@ -1121,7 +1121,9 @@ function handleInspectMessage(
1121
1121
 
1122
1122
  if (stream !== undefined && typeof msg.sinceMs === 'number') {
1123
1123
  for (const event of stream.scan({ sinceTs: msg.sinceMs, target: { kind: 'broadcast' } })) {
1124
- sendInspect(ws, { type: 'frame', ts: event.ts, payload: broadcastEventToFrame(event) })
1124
+ const payload = broadcastEventToFrame(event)
1125
+ if (!isFrameForWatchedSession(payload, msg.sessionId)) continue
1126
+ sendInspect(ws, { type: 'frame', ts: event.ts, payload })
1125
1127
  }
1126
1128
  for (const event of stream.scan({ sinceTs: msg.sinceMs, target: { kind: 'cron' } })) {
1127
1129
  sendInspect(ws, {
@@ -1143,7 +1145,9 @@ function handleInspectMessage(
1143
1145
 
1144
1146
  if (stream !== undefined) {
1145
1147
  ws.data.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (event) => {
1146
- sendInspect(ws, { type: 'frame', ts: event.ts, payload: broadcastEventToFrame(event) })
1148
+ const payload = broadcastEventToFrame(event)
1149
+ if (!isFrameForWatchedSession(payload, msg.sessionId)) return
1150
+ sendInspect(ws, { type: 'frame', ts: event.ts, payload })
1147
1151
  })
1148
1152
  ws.data.unsubCron = stream.subscribe({ target: { kind: 'cron' } }, (event) => {
1149
1153
  sendInspect(ws, {
@@ -1171,6 +1175,15 @@ function broadcastEventToFrame(event: StreamMessage): InspectFramePayload {
1171
1175
  }
1172
1176
  }
1173
1177
 
1178
+ // Channel inbounds are published as global broadcasts, so every inspect client
1179
+ // receives every session's inbounds. Drop the ones that don't belong to the
1180
+ // session being watched. Non-inbound broadcasts (subagent completions, cron,
1181
+ // tunnels) stay global — they carry no session identity here.
1182
+ function isFrameForWatchedSession(payload: InspectFramePayload, watchedSessionId: string): boolean {
1183
+ if (payload.kind !== 'channel_inbound') return true
1184
+ return payload.sessionId === watchedSessionId
1185
+ }
1186
+
1174
1187
  function readChannelInboundBroadcast(payload: unknown): InspectFramePayload | null {
1175
1188
  if (typeof payload !== 'object' || payload === null) return null
1176
1189
  const p = payload as Record<string, unknown>
@@ -1191,6 +1204,7 @@ function readChannelInboundBroadcast(payload: unknown): InspectFramePayload | nu
1191
1204
  if (decision !== 'engage' && decision !== 'observe' && decision !== 'denied' && decision !== 'claim') return null
1192
1205
  return {
1193
1206
  kind: 'channel_inbound',
1207
+ ...(typeof p.sessionId === 'string' ? { sessionId: p.sessionId } : {}),
1194
1208
  adapter: p.adapter,
1195
1209
  workspace: p.workspace,
1196
1210
  chat: p.chat,
@@ -24,10 +24,4 @@ export {
24
24
  type TunnelSnapshot,
25
25
  } from './protocol'
26
26
 
27
- export {
28
- formatLocalDate,
29
- formatLocalDateTime,
30
- formatLocalWeekday,
31
- type LocalWeekday,
32
- resolveLocalTimezoneName,
33
- } from './local-time'
27
+ export { formatLocalDate, formatLocalDateTime, formatLocalWeekday, resolveLocalTimezoneName } from './local-time'
@@ -37,34 +37,26 @@ export function resolveLocalTimezoneName(): string {
37
37
  }
38
38
  }
39
39
 
40
- // English + Korean weekday name pair for a given Date. The per-turn time
41
- // anchor renders both so the model has the answer to "what day is it"
42
- // without computing weekday-from-ISO-date — a step LLMs get wrong often
43
- // enough to matter, especially when answering in a non-English language.
44
- // Pre-computing in both candidate reply languages removes the arithmetic
45
- // step entirely instead of trusting the model to do it correctly each
46
- // turn.
40
+ // English weekday name for a given Date. The per-turn time anchor renders
41
+ // it so the model has the answer to "what day is it" without computing
42
+ // weekday-from-ISO-date — a step LLMs get wrong often enough to matter.
43
+ // Pre-computing the weekday removes the arithmetic step entirely instead
44
+ // of trusting the model to do it correctly each turn. English only:
45
+ // TypeClaw's users are global, so a single canonical language keeps the
46
+ // anchor compact and lets each agent's SOUL.md decide its reply language.
47
47
  //
48
- // Uses Intl.DateTimeFormat with explicit locales. No `timeZone` option:
48
+ // Uses Intl.DateTimeFormat with an explicit locale. No `timeZone` option:
49
49
  // the container's local clock is already host-local (the entrypoint
50
50
  // propagates TZ via `-e TZ=<host-tz>`), so the runtime's default zone is
51
- // the one the user sees. Both locales fall back to the hand-rolled
52
- // 7-entry lookup if Intl throws (no-tzdata, locked-down sandbox) — the
53
- // fallback names stay readable and never make the prefix empty.
51
+ // the one the user sees. Falls back to the hand-rolled 7-entry lookup if
52
+ // Intl throws (no-tzdata, locked-down sandbox) — the fallback names stay
53
+ // readable and never make the prefix empty.
54
54
  const WEEKDAYS_EN = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] as const
55
- const WEEKDAYS_KO = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'] as const
56
55
 
57
- export type LocalWeekday = { en: string; ko: string }
58
-
59
- export function formatLocalWeekday(date: Date = new Date()): LocalWeekday {
60
- const dow = date.getDay()
61
- const fallback: LocalWeekday = { en: WEEKDAYS_EN[dow]!, ko: WEEKDAYS_KO[dow]! }
56
+ export function formatLocalWeekday(date: Date = new Date()): string {
62
57
  try {
63
- return {
64
- en: new Intl.DateTimeFormat('en-US', { weekday: 'long' }).format(date),
65
- ko: new Intl.DateTimeFormat('ko-KR', { weekday: 'long' }).format(date),
66
- }
58
+ return new Intl.DateTimeFormat('en-US', { weekday: 'long' }).format(date)
67
59
  } catch {
68
- return fallback
60
+ return WEEKDAYS_EN[date.getDay()]!
69
61
  }
70
62
  }
@@ -101,6 +101,10 @@ export type InspectFramePayload =
101
101
  // text — no batching, no compose-prompt wrapping.
102
102
  | {
103
103
  kind: 'channel_inbound'
104
+ // Channel session this inbound belongs to. Absent for denied/claim
105
+ // intercepts that fire before a session exists. The inspect server drops
106
+ // frames whose sessionId does not match the watched session.
107
+ sessionId?: string
104
108
  adapter: string
105
109
  workspace: string
106
110
  chat: string
@@ -9,6 +9,10 @@ GitHub renders normal Markdown in issues, PRs, discussions, and review comments.
9
9
  - There is no typing indicator.
10
10
  - For PR review threads, keep `thread` set to reply in-place. Omit `thread` for a top-level PR/issue comment.
11
11
 
12
+ ## Mid-turn status replies need `continue: true`
13
+
14
+ A successful `channel_reply` ends your turn by default — the runtime stops the model right after the reply lands. That is correct for a final answer, but it will **silently truncate** a turn that still has work to do. If you post a status line like "Reviewing now, I'll be back with findings" and then expect to keep working (fetch the diff, spawn the reviewer, post the review) in the **same** turn, you must call `channel_reply({ text: "…", continue: true })`. Without `continue: true`, the turn ends at that status reply and the review never runs. Reserve `continue: true` for genuine multi-step turns; the final reply that wraps up the turn omits it.
15
+
12
16
  ## Opening new issues and PRs
13
17
 
14
18
  The `gh` CLI is pre-authenticated via `GH_TOKEN` (injected by the adapter at startup). Use it to open new issues or PRs:
@@ -39,6 +43,8 @@ Why delegate: the `reviewer` subagent runs on the `deep` model profile, loads a
39
43
 
40
44
  2. **Spawn the `reviewer` subagent with the PR target.** Use `run_in_background: true` so you stay responsive while the deep model works. Pass the PR URL (or `owner/repo#N`) plus any context the requester gave you (focus areas, specific files, etc.) so the reviewer knows what the requester cares about.
41
45
 
46
+ If you post an "on it" acknowledgement before fetching the diff or spawning the reviewer, it **must** be `channel_reply({ text: "…", continue: true })` — a bare reply ends the turn and the review never starts (see "Mid-turn status replies need `continue: true`" above).
47
+
42
48
  The reviewer will fetch the diff itself (`gh pr diff`, `gh api /repos/.../pulls/<n>`), load the matching skill (`code-review` for a code PR; `general` for a mixed-format change), and return a `<review>` block.
43
49
 
44
50
  3. **Wait for the completion `<system-reminder>`,** then call `subagent_output({ task_id })` to read the reviewer's final assistant message. The structured payload looks like: