typeclaw 0.15.0 → 0.15.1

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.1",
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
@@ -138,34 +138,37 @@ export function createChannelReplyTool({
138
138
  // Without this echo, a model that splits a multi-part reply has no
139
139
  // way to tell "did I already send part 1?" from "I haven't started
140
140
  // yet", and routinely re-sends near-duplicates within the same turn
141
- // (observed in production: two consecutive identical
142
- // greeting messages to one prompt).
141
+ // (observed in production: two consecutive identical greeting messages
142
+ // to one prompt).
143
143
  //
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
144
+ // The echo is the model's OWN words, which is uniquely seductive to
145
+ // "reply" to, so on the success path we wrap the whole result in the
146
+ // strong SYSTEM MESSAGE fence (`fenceToolResult`) rather than the weak
147
+ // `[system: tool result...]` prefix the prefix did not stop Kimi from
148
+ // answering its own echo and looping (PR #481). Denials carry no echoed
149
+ // prose (just machine error text), so they keep the lighter prefix.
150
+ if (result.ok) {
151
+ const echo = renderOutboundEcho(text, attachments)
152
+ const receipt = `posted to ${origin.adapter}:${origin.workspace}/${origin.chat}: ${echo}`
153
+ const hint = consecutiveSendHint(
154
+ router.getConsecutiveSendCount({
155
+ adapter: origin.adapter,
156
+ workspace: origin.workspace,
157
+ chat: origin.chat,
158
+ thread: origin.thread,
159
+ }),
160
+ )
161
+ // Keep fenceToolResult here — do NOT "unify" the success branch back to
162
+ // TOOL_RESULT_PREFIX to match the denial branch below. The prefix is
163
+ // intentionally weaker and is safe ONLY because denials carry no echoed
164
+ // prose; the success result does, and the weak prefix let Kimi loop.
165
+ return {
166
+ content: [{ type: 'text' as const, text: `${fenceToolResult(receipt)}${hint}` }],
167
+ details,
168
+ }
169
+ }
167
170
  return {
168
- content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}${body}` }],
171
+ content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}channel_reply denied: ${result.error}` }],
169
172
  details,
170
173
  }
171
174
  },
@@ -188,6 +191,13 @@ export function renderEcho(text: string): string {
188
191
  return `${JSON.stringify(text.slice(0, ECHO_MAX_CHARS))}... (${text.length} chars total)`
189
192
  }
190
193
 
194
+ // DO NOT remove this echo or replace it with a hash/length-only "receipt" to
195
+ // stop the self-reply loop (PR #481). That trade was tried and rejected: the
196
+ // echo is the model's only view of what it already said (the inbound path
197
+ // drops self-authored messages), so without the FULL text a split reply
198
+ // re-sends near-duplicates — the exact bug 58c62c1 added the echo to fix, and
199
+ // a fingerprint cannot catch paraphrased near-dupes. The loop is solved by
200
+ // FENCING this echo (see fenceToolResult call site below), not by removing it.
191
201
  export function renderOutboundEcho(
192
202
  text: string | undefined,
193
203
  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,54 @@ 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
+ const installChannelReplyTerminalHook = (live: LiveSession): void => {
1252
+ const { agent } = live.session
1253
+ const prior = agent.afterToolCall
1254
+ agent.afterToolCall = async (context, signal) => {
1255
+ const result = prior ? await prior(context, signal) : undefined
1256
+ const succeeded =
1257
+ context.toolCall.name === 'channel_reply' &&
1258
+ !context.isError &&
1259
+ (context.result.details as { ok?: unknown } | undefined)?.ok === true
1260
+ if (succeeded && agent.signal?.aborted !== true) {
1261
+ logger.info(`[channels] ${live.keyId} terminal_after_channel_reply`)
1262
+ agent.abort()
1263
+ }
1264
+ return result
1265
+ }
1266
+ }
1267
+
1268
+ // Override pi-ai's hidden `Math.min(model.maxTokens, 32000)` output cap for
1269
+ // channel sessions by threading an explicit `maxTokens` into every stream
1270
+ // call. See CHANNEL_MAX_OUTPUT_TOKENS for why. Composes the existing streamFn
1271
+ // (pi's default `streamSimple` unless a proxy was installed) and only fills
1272
+ // `maxTokens` when the caller left it unset, so an explicit per-call value
1273
+ // still wins.
1274
+ const installChannelOutputCap = (live: LiveSession): void => {
1275
+ const { agent } = live.session
1276
+ const inner = agent.streamFn
1277
+ agent.streamFn = (model, context, options) =>
1278
+ inner(model, context, { ...options, maxTokens: options?.maxTokens ?? CHANNEL_MAX_OUTPUT_TOKENS })
1279
+ }
1280
+
1219
1281
  const startTypingHeartbeat = (live: LiveSession): void => {
1220
1282
  if (live.typingTimedOut || live.typingStopPromise) return
1221
1283
  if (live.destroyed) return
@@ -1461,13 +1523,21 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1461
1523
  }, wait)
1462
1524
  }
1463
1525
 
1464
- const publishInbound = (event: InboundMessage, decision: 'engage' | 'observe' | 'denied' | 'claim'): void => {
1526
+ const publishInbound = (
1527
+ event: InboundMessage,
1528
+ decision: 'engage' | 'observe' | 'denied' | 'claim',
1529
+ // Undefined before a session exists (denied/claim intercepts). Carried so a
1530
+ // session-scoped `typeclaw inspect` only sees its own session's inbounds —
1531
+ // the broadcast otherwise fans out to every inspect client.
1532
+ sessionId?: string,
1533
+ ): void => {
1465
1534
  if (stream === undefined) return
1466
1535
  try {
1467
1536
  stream.publish({
1468
1537
  target: { kind: 'broadcast' },
1469
1538
  payload: {
1470
1539
  kind: 'channel-inbound',
1540
+ ...(sessionId !== undefined ? { sessionId } : {}),
1471
1541
  adapter: event.adapter,
1472
1542
  workspace: event.workspace,
1473
1543
  chat: event.chat,
@@ -1604,7 +1674,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1604
1674
  })
1605
1675
 
1606
1676
  if (decision === 'observe') {
1607
- publishInbound(event, 'observe')
1677
+ publishInbound(event, 'observe', live.sessionId)
1608
1678
  // Log every observe so an unanswered mention is diagnosable from logs
1609
1679
  // alone instead of "routed but no prompting" silence. The bracketed
1610
1680
  // shape mirrors `prompting batch=` so log scraping can pair them.
@@ -1613,7 +1683,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1613
1683
  return
1614
1684
  }
1615
1685
 
1616
- publishInbound(event, 'engage')
1686
+ publishInbound(event, 'engage', live.sessionId)
1617
1687
 
1618
1688
  updateLoopGuard(live, event)
1619
1689
 
@@ -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