typeclaw 0.9.1 → 0.10.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.
Files changed (62) hide show
  1. package/package.json +2 -2
  2. package/scripts/require-parallel.ts +41 -15
  3. package/src/agent/index.ts +9 -7
  4. package/src/agent/live-subagents.ts +0 -1
  5. package/src/agent/session-origin.ts +10 -0
  6. package/src/agent/subagent-completion-reminder.ts +4 -1
  7. package/src/agent/system-prompt.ts +5 -5
  8. package/src/agent/tools/restart.ts +13 -2
  9. package/src/agent/tools/spawn-subagent.ts +0 -1
  10. package/src/agent/tools/subagent-output.ts +3 -51
  11. package/src/bundled-plugins/memory/dreaming-state.ts +51 -2
  12. package/src/bundled-plugins/memory/index.ts +55 -25
  13. package/src/bundled-plugins/memory/memory-retrieval.ts +1 -1
  14. package/src/bundled-plugins/memory/migration.ts +21 -17
  15. package/src/bundled-plugins/memory/stream-io.ts +71 -1
  16. package/src/bundled-plugins/security/index.ts +19 -17
  17. package/src/bundled-plugins/security/permissions.ts +9 -8
  18. package/src/bundled-plugins/security/policies/cron-promotion.ts +26 -9
  19. package/src/bundled-plugins/security/policies/git-exfil.ts +23 -15
  20. package/src/bundled-plugins/security/policies/role-promotion.ts +25 -18
  21. package/src/channels/manager.ts +7 -0
  22. package/src/channels/router.ts +267 -14
  23. package/src/channels/schema.ts +22 -1
  24. package/src/cli/compose.ts +23 -2
  25. package/src/cli/cron.ts +1 -1
  26. package/src/cli/inspect.ts +105 -12
  27. package/src/cli/logs.ts +17 -2
  28. package/src/cli/role.ts +2 -2
  29. package/src/compose/logs.ts +8 -4
  30. package/src/config/config.ts +8 -0
  31. package/src/config/providers.ts +18 -0
  32. package/src/container/index.ts +1 -1
  33. package/src/container/logs.ts +38 -11
  34. package/src/cron/bridge.ts +25 -4
  35. package/src/hostd/daemon.ts +44 -24
  36. package/src/hostd/portbroker-manager.ts +19 -3
  37. package/src/init/dockerfile.ts +199 -4
  38. package/src/init/gitignore.ts +8 -0
  39. package/src/inspect/index.ts +42 -5
  40. package/src/inspect/live.ts +32 -1
  41. package/src/inspect/loop.ts +20 -0
  42. package/src/inspect/render.ts +32 -0
  43. package/src/inspect/replay.ts +14 -0
  44. package/src/inspect/types.ts +26 -0
  45. package/src/permissions/builtins.ts +29 -21
  46. package/src/permissions/permissions.ts +32 -5
  47. package/src/role-claim/code.ts +9 -9
  48. package/src/role-claim/controller.ts +3 -2
  49. package/src/role-claim/match-rule.ts +14 -19
  50. package/src/role-claim/pending.ts +2 -2
  51. package/src/run/index.ts +1 -0
  52. package/src/server/index.ts +59 -19
  53. package/src/shared/protocol.ts +30 -0
  54. package/src/skills/typeclaw-codex-cli/SKILL.md +324 -0
  55. package/src/skills/typeclaw-codex-cli/references/auth-flow.md +144 -0
  56. package/src/skills/typeclaw-codex-cli/references/stop-hook.md +92 -0
  57. package/src/skills/typeclaw-codex-cli/references/tmux-driving.md +239 -0
  58. package/src/skills/typeclaw-config/SKILL.md +39 -32
  59. package/src/skills/typeclaw-config/references/recommended-mounts.md +233 -0
  60. package/src/skills/typeclaw-permissions/SKILL.md +24 -18
  61. package/src/test-helpers/wait-for.ts +15 -7
  62. package/typeclaw.schema.json +111 -10
@@ -29,21 +29,27 @@ export type BuiltinRoleSpec = {
29
29
  readonly permissions: readonly string[]
30
30
  }
31
31
 
32
- // Owner carries low + medium tier strings explicitly AND the wildcard
33
- // sentinel. The sentinel expands to plugin-contributed `security.bypass.*`
34
- // strings minus the security plugin's `ownerWildcardExclusions` (today:
35
- // `security.bypass.high` plus high-tier per-guard strings). Net effect:
36
- // owner auto-bypasses every low- and medium-tier guard, and high-tier
37
- // guards require per-call ack from owner too (the audience-leak rule —
38
- // owner-in-public-channel must not silently post credentials).
32
+ // Role-to-tier defaults form a strict tower:
33
+ // owner → bypass.low + bypass.medium + bypass.high
34
+ // trusted bypass.low + bypass.medium
35
+ // member → bypass.low
36
+ // guest → no bypass
39
37
  //
40
- // Trusted carries only `security.bypass.low`. Trusted does NOT carry the
41
- // pre-PR per-guard grants (`bypassSecretExfilBash`, `bypassGitExfil`):
42
- // those guards are medium/high under the audience-leak axis and per-guard
43
- // grants would re-introduce exactly the bypass holes the tier system
44
- // exists to prevent. Operators who want the pre-PR ergonomics can add the
45
- // per-guard strings explicitly to `roles.trusted.permissions[]` in
46
- // typeclaw.json that path stays alive forever.
38
+ // `canBypass` in the bundled security plugin checks the specific tier
39
+ // string for the guard's severity, so each role must carry every tier
40
+ // string at or below its cap (tiers do not cascade implicitly).
41
+ //
42
+ // Owner also carries the wildcard sentinel: the sentinel expands to every
43
+ // plugin-contributed `security.bypass.*` string minus
44
+ // `ownerWildcardExclusions`. The bundled security plugin no longer excludes
45
+ // high-tier strings (owner is meant to bypass them by default under this
46
+ // model), so the sentinel covers per-guard high-tier strings too.
47
+ //
48
+ // Tradeoff: this gives owner audience-leak bypass without per-call ack.
49
+ // The owner-in-public-channel risk is now load-bearing on the operator
50
+ // scoping `roles.owner.match[]` tightly. Default match is TUI-only, where
51
+ // a human is present; configs that widen owner to a channel author should
52
+ // understand they have re-opened audience-leak for that author.
47
53
  export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> = {
48
54
  owner: {
49
55
  match: [{ kind: 'tui' }],
@@ -57,6 +63,7 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
57
63
  CORE_PERMISSIONS.subagentSpawnOperator,
58
64
  'security.bypass.low',
59
65
  'security.bypass.medium',
66
+ 'security.bypass.high',
60
67
  OWNER_SECURITY_WILDCARD,
61
68
  ],
62
69
  },
@@ -70,6 +77,7 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
70
77
  CORE_PERMISSIONS.subagentOutput,
71
78
  CORE_PERMISSIONS.subagentSpawnOperator,
72
79
  'security.bypass.low',
80
+ 'security.bypass.medium',
73
81
  ],
74
82
  },
75
83
  member: {
@@ -79,6 +87,7 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
79
87
  CORE_PERMISSIONS.subagentSpawn,
80
88
  CORE_PERMISSIONS.subagentCancel,
81
89
  CORE_PERMISSIONS.subagentOutput,
90
+ 'security.bypass.low',
82
91
  ],
83
92
  },
84
93
  guest: {
@@ -88,13 +97,12 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
88
97
  }
89
98
 
90
99
  // Expands the owner wildcard sentinel against plugin-contributed
91
- // `security.bypass.*` strings. `wildcardExclusions` is an optional set of
92
- // permission strings the sentinel must NOT expand to used by the
93
- // bundled security plugin to exclude `security.bypass.high` AND the
94
- // per-guard strings for high-tier guards, so the wildcard does not
95
- // auto-grant audience-leak bypass to owner. Explicit operator grants of
96
- // those strings in `roles.owner.permissions[]` still take effect (they
97
- // flow through the non-sentinel branch).
100
+ // `security.bypass.*` strings. `wildcardExclusions` lets plugins opt
101
+ // specific strings OUT of the wildcard expansion. The bundled security
102
+ // plugin no longer excludes any high-tier strings — owner bypasses every
103
+ // security tier by default under the current role-tower model. The
104
+ // parameter is preserved for third-party plugins that want a different
105
+ // shape (e.g. a future audit-only plugin that never auto-flows to owner).
98
106
  export function expandOwnerWildcard(
99
107
  ownerPermissions: readonly string[],
100
108
  pluginContributed: readonly string[],
@@ -152,6 +152,29 @@ export function createPermissionService(opts: CreatePermissionServiceOptions = {
152
152
  }
153
153
  }
154
154
 
155
+ // Walk order: owner, trusted, custom roles (in REVERSE declaration order),
156
+ // member, guest. First role whose `match[]` covers the origin wins.
157
+ //
158
+ // Built-in tower: owner > trusted > member > guest. Pinning the tower
159
+ // ahead of any user-declared rule closes a load-bearing footgun in the
160
+ // previous pure-declaration-order resolver: declaring
161
+ // `member.match: ["*"]` before `owner.match: [...]` resolved every
162
+ // channel session — INCLUDING the owner's — to `member`, because the
163
+ // wildcard matched first. The rolePromotion guard then made it
164
+ // un-fixable from inside the demoted session (a member-resolved speaker
165
+ // cannot rewrite `roles` without a TUI-issued ack).
166
+ //
167
+ // Custom roles use REVERSE declaration order: later declarations override
168
+ // earlier ones. This matches the standard "later config wins" mental
169
+ // model — when an operator adds a new role with the same match-scope as
170
+ // an existing one (or appends a new author-pinned override to an existing
171
+ // broad rule), the newer entry takes precedence. The previous "earlier
172
+ // wins" was an arbitrary consequence of map iteration order rather than
173
+ // a deliberate semantic.
174
+ //
175
+ // Custom roles cannot self-promote above trusted (no inherent severity
176
+ // guarantee) and cannot demote themselves below member (declaring a custom
177
+ // role implies the operator wants it to win against bottom catch-alls).
155
178
  function buildRoleTable(
156
179
  roles: RolesConfig,
157
180
  pluginPermissions: readonly string[],
@@ -160,16 +183,20 @@ function buildRoleTable(
160
183
  const out: ResolvedRole[] = []
161
184
  const seen = new Set<string>()
162
185
 
163
- for (const name of Object.keys(roles)) {
164
- if (seen.has(name)) continue
186
+ const emit = (name: string): void => {
187
+ if (seen.has(name)) return
165
188
  seen.add(name)
166
189
  out.push(resolveOne(name, roles[name], pluginPermissions, ownerWildcardExclusions))
167
190
  }
168
191
 
169
- for (const name of BUILTIN_ROLE_NAMES) {
170
- if (seen.has(name)) continue
171
- out.push(resolveOne(name, undefined, pluginPermissions, ownerWildcardExclusions))
192
+ emit('owner')
193
+ emit('trusted')
194
+ const customRoles = Object.keys(roles).filter((name) => !isBuiltinRoleName(name))
195
+ for (let i = customRoles.length - 1; i >= 0; i--) {
196
+ emit(customRoles[i]!)
172
197
  }
198
+ emit('member')
199
+ emit('guest')
173
200
 
174
201
  return out
175
202
  }
@@ -1,17 +1,17 @@
1
1
  import { randomBytes } from 'node:crypto'
2
2
 
3
3
  // Role-claim codes are short, human-typeable tokens the operator sends from
4
- // their host CLI to the bot via a channel DM to prove ownership of that
5
- // channel identity. Shape: `claim-XXXX-YYYY` where each block is 4 chars
6
- // from a Crockford-style base32 alphabet (0-9 + A-Z minus I, L, O, U to
7
- // dodge OCR-confusable / profane shapes). 8 chars * 5 bits = 40 bits of
8
- // entropy, which is overkill for a TTL'd in-memory window but cheap to
9
- // display and dictate over voice.
4
+ // their host CLI to the bot in any chat (DM, group, channel) to prove
5
+ // ownership of that channel identity. Shape: `claim-XXXX-YYYY` where each
6
+ // block is 4 chars from a Crockford-style base32 alphabet (0-9 + A-Z minus
7
+ // I, L, O, U to dodge OCR-confusable / profane shapes). 8 chars * 5 bits =
8
+ // 40 bits of entropy, which is overkill for a TTL'd in-memory window but
9
+ // cheap to display and dictate over voice.
10
10
  //
11
11
  // The `claim-` prefix lets the channel router recognize potential claim
12
- // attempts in a DM body without scanning the whole text for hex blocks,
13
- // and distinguishes claim DMs from normal first-message text like "hi"
14
- // which would otherwise need a regex of its own to disambiguate.
12
+ // attempts in inbound text without scanning the whole body for hex blocks,
13
+ // and distinguishes claim messages from normal first-message text like
14
+ // "hi" which would otherwise need a regex of its own to disambiguate.
15
15
 
16
16
  export const CLAIM_CODE_PREFIX = 'claim-'
17
17
 
@@ -10,8 +10,9 @@ import { createPendingClaimRegistry, type PendingClaim, type PendingClaimRegistr
10
10
  //
11
11
  // 1. The host CLI (typeclaw role claim) opens a WS and sends `claim_start`.
12
12
  // 2. The WS server forwards that to controller.startClaim().
13
- // 3. The channel router's claimHandler (also wired here) intercepts DMs
14
- // bearing the code and calls controller.tryConsumeInbound().
13
+ // 3. The channel router's claimHandler (also wired here) intercepts any
14
+ // inbound bearing the code (DM, group, or channel) and calls
15
+ // controller.tryConsumeInbound().
15
16
  // 4. On consume, the controller writes to typeclaw.json#roles.<role>.match
16
17
  // via grantRole, then reloads the live PermissionService so the new
17
18
  // match rule takes effect without a container restart.
@@ -1,15 +1,19 @@
1
1
  // Builds a canonical match-rule DSL string from an inbound channel origin,
2
- // for the role table. Output shapes:
2
+ // for the role table. Output shape is always platform-wide + author:
3
3
  //
4
- // slack:T0123 author:U_ALICE
5
- // discord:9999 author:U_ALICE
6
- // telegram:42 author:U_ALICE
7
- // kakao:dm/<chatId> author:<authorId>
4
+ // slack:* author:<authorId>
5
+ // discord:* author:<authorId>
6
+ // telegram:* author:<authorId>
7
+ // kakao:* author:<authorId>
8
8
  //
9
- // The author qualifier is always emitted so a claim grants the specific
10
- // human, not the whole workspace. To grant the whole workspace, the
11
- // operator edits typeclaw.json by hand or runs a future `typeclaw role grant`
12
- // without --claim.
9
+ // "Platform-wide" means every chat the adapter sees on that platform
10
+ // DMs, group chats, and threads alike gated by the author qualifier so
11
+ // only this specific human is matched. The intent is: once an operator
12
+ // proves they control a channel identity (by sending a code to the bot),
13
+ // they keep their role wherever they speak from on the same platform. To
14
+ // scope tighter (e.g. one workspace, one chat), the operator edits
15
+ // typeclaw.json by hand; the claim flow is deliberately broad because
16
+ // re-claiming on every new chat would be tedious for the common case.
13
17
 
14
18
  import type { ChannelKey } from '@/channels/types'
15
19
 
@@ -31,14 +35,5 @@ const ADAPTER_TO_PLATFORM: Record<ChannelKey['adapter'], 'slack' | 'discord' | '
31
35
 
32
36
  export function formatClaimMatchRule(origin: PartialChannelOrigin): string {
33
37
  const platform = ADAPTER_TO_PLATFORM[origin.adapter]
34
- const authorQual = ` author:${origin.authorId}`
35
- if (origin.adapter === 'kakaotalk') {
36
- // Kakao has no workspace; routes use dm/group/open buckets. We can't
37
- // know which bucket from a partial origin alone (adapter-side classifies
38
- // it), so claim flows are restricted to DM and we emit the specific
39
- // chat-id form so the rule grants only this 1:1 conversation, not every
40
- // DM the agent is in.
41
- return `${platform}:dm/${origin.chat}${authorQual}`
42
- }
43
- return `${platform}:${origin.workspace}${authorQual}`
38
+ return `${platform}:* author:${origin.authorId}`
44
39
  }
@@ -21,8 +21,8 @@ export type PendingClaimRegistry = {
21
21
  cancel: (code: string) => boolean
22
22
  current: () => PendingClaim | null
23
23
  // Snapshot of consumption result without actually committing the grant.
24
- // The router calls this on every DM-shaped inbound; the grant only fires
25
- // when the result is 'consumed'.
24
+ // The router calls this on every claim-code-bearing inbound; the grant
25
+ // only fires when the result is 'consumed'.
26
26
  tryConsume: (
27
27
  code: string,
28
28
  origin: PartialChannelOrigin,
package/src/run/index.ts CHANGED
@@ -214,6 +214,7 @@ export async function startAgent({
214
214
  }),
215
215
  permissions: pluginsLoaded.permissions,
216
216
  claimHandler: claimController.claimHandler,
217
+ stream,
217
218
  })
218
219
 
219
220
  const createSessionForSubagent: import('@/agent/subagents').CreateSessionForSubagent = async (
@@ -1062,15 +1062,7 @@ function handleInspectMessage(
1062
1062
 
1063
1063
  if (stream !== undefined && typeof msg.sinceMs === 'number') {
1064
1064
  for (const event of stream.scan({ sinceTs: msg.sinceMs, target: { kind: 'broadcast' } })) {
1065
- sendInspect(ws, {
1066
- type: 'frame',
1067
- ts: event.ts,
1068
- payload: {
1069
- kind: 'broadcast',
1070
- payload: event.payload,
1071
- ...(event.meta !== undefined ? { meta: event.meta } : {}),
1072
- },
1073
- })
1065
+ sendInspect(ws, { type: 'frame', ts: event.ts, payload: broadcastEventToFrame(event) })
1074
1066
  }
1075
1067
  for (const event of stream.scan({ sinceTs: msg.sinceMs, target: { kind: 'cron' } })) {
1076
1068
  sendInspect(ws, {
@@ -1092,15 +1084,7 @@ function handleInspectMessage(
1092
1084
 
1093
1085
  if (stream !== undefined) {
1094
1086
  ws.data.unsubBroadcast = stream.subscribe({ target: { kind: 'broadcast' } }, (event) => {
1095
- sendInspect(ws, {
1096
- type: 'frame',
1097
- ts: event.ts,
1098
- payload: {
1099
- kind: 'broadcast',
1100
- payload: event.payload,
1101
- ...(event.meta !== undefined ? { meta: event.meta } : {}),
1102
- },
1103
- })
1087
+ sendInspect(ws, { type: 'frame', ts: event.ts, payload: broadcastEventToFrame(event) })
1104
1088
  })
1105
1089
  ws.data.unsubCron = stream.subscribe({ target: { kind: 'cron' } }, (event) => {
1106
1090
  sendInspect(ws, {
@@ -1118,6 +1102,52 @@ function extractJobId(target: StreamMessage['target']): string {
1118
1102
  return target.kind === 'cron' ? target.jobId : ''
1119
1103
  }
1120
1104
 
1105
+ function broadcastEventToFrame(event: StreamMessage): InspectFramePayload {
1106
+ const inbound = readChannelInboundBroadcast(event.payload)
1107
+ if (inbound !== null) return inbound
1108
+ return {
1109
+ kind: 'broadcast',
1110
+ payload: event.payload,
1111
+ ...(event.meta !== undefined ? { meta: event.meta } : {}),
1112
+ }
1113
+ }
1114
+
1115
+ function readChannelInboundBroadcast(payload: unknown): InspectFramePayload | null {
1116
+ if (typeof payload !== 'object' || payload === null) return null
1117
+ const p = payload as Record<string, unknown>
1118
+ if (p.kind !== 'channel-inbound') return null
1119
+ if (typeof p.adapter !== 'string') return null
1120
+ if (typeof p.workspace !== 'string') return null
1121
+ if (typeof p.chat !== 'string') return null
1122
+ if (!(p.thread === null || typeof p.thread === 'string')) return null
1123
+ if (typeof p.authorId !== 'string') return null
1124
+ if (typeof p.authorName !== 'string') return null
1125
+ if (typeof p.authorIsBot !== 'boolean') return null
1126
+ if (typeof p.isDm !== 'boolean') return null
1127
+ if (typeof p.isBotMention !== 'boolean') return null
1128
+ if (typeof p.text !== 'string') return null
1129
+ if (typeof p.externalMessageId !== 'string') return null
1130
+ if (typeof p.ts !== 'number') return null
1131
+ const decision = p.decision
1132
+ if (decision !== 'engage' && decision !== 'observe' && decision !== 'denied' && decision !== 'claim') return null
1133
+ return {
1134
+ kind: 'channel_inbound',
1135
+ adapter: p.adapter,
1136
+ workspace: p.workspace,
1137
+ chat: p.chat,
1138
+ thread: p.thread,
1139
+ authorId: p.authorId,
1140
+ authorName: p.authorName,
1141
+ authorIsBot: p.authorIsBot,
1142
+ isDm: p.isDm,
1143
+ isBotMention: p.isBotMention,
1144
+ text: p.text,
1145
+ externalMessageId: p.externalMessageId,
1146
+ ts: p.ts,
1147
+ decision,
1148
+ }
1149
+ }
1150
+
1121
1151
  function forwardAgentEventToInspect(
1122
1152
  ws: InspectWs,
1123
1153
  event: unknown,
@@ -1128,10 +1158,20 @@ function forwardAgentEventToInspect(
1128
1158
  const e = event as { type?: unknown }
1129
1159
  const now = Date.now()
1130
1160
  if (e.type === 'message_update') {
1131
- const ev = event as { assistantMessageEvent?: { type?: unknown; delta?: unknown } }
1161
+ const ev = event as { assistantMessageEvent?: { type?: unknown; delta?: unknown; content?: unknown } }
1132
1162
  const ame = ev.assistantMessageEvent
1133
1163
  if (ame?.type === 'text_delta' && typeof ame.delta === 'string') {
1134
1164
  sendInspect(ws, { type: 'frame', ts: now, payload: { kind: 'text_delta', sessionId, delta: ame.delta } })
1165
+ return
1166
+ }
1167
+ if (ame?.type === 'thinking_delta' && typeof ame.delta === 'string') {
1168
+ sendInspect(ws, { type: 'frame', ts: now, payload: { kind: 'thinking_delta', sessionId, delta: ame.delta } })
1169
+ return
1170
+ }
1171
+ if (ame?.type === 'thinking_end') {
1172
+ const text = typeof ame.content === 'string' ? ame.content : ''
1173
+ sendInspect(ws, { type: 'frame', ts: now, payload: { kind: 'thinking_end', sessionId, text } })
1174
+ return
1135
1175
  }
1136
1176
  return
1137
1177
  }
@@ -57,6 +57,12 @@ export type InspectClientMessage = {
57
57
 
58
58
  export type InspectFramePayload =
59
59
  | { kind: 'text_delta'; sessionId: string; delta: string }
60
+ // Reasoning trace from the model, streamed as deltas like text. `thinking_end`
61
+ // closes a thinking block; `text` is the joined deltas (empty when redacted).
62
+ // `redacted: true` means the upstream provider hid the content behind a
63
+ // safety filter and only the opaque continuation payload survives.
64
+ | { kind: 'thinking_delta'; sessionId: string; delta: string }
65
+ | { kind: 'thinking_end'; sessionId: string; text: string; redacted?: boolean }
60
66
  | { kind: 'tool_start'; sessionId: string; toolCallId: string; name: string; args: unknown }
61
67
  | {
62
68
  kind: 'tool_end'
@@ -87,6 +93,30 @@ export type InspectFramePayload =
87
93
  }
88
94
  | { kind: 'broadcast'; payload: unknown; meta?: Record<string, string> }
89
95
  | { kind: 'cron-fire'; jobId: string; payload: unknown }
96
+ // Channel inbound message observed by the router. Surfaced regardless
97
+ // of the engagement decision so inspect can show what the agent saw,
98
+ // not just what it chose to act on. `decision` mirrors the router's
99
+ // EngagementDecision plus 'denied' (channel.respond gate) and 'claim'
100
+ // (role-claim intercept) for completeness. `text` is the raw inbound
101
+ // text — no batching, no compose-prompt wrapping.
102
+ | {
103
+ kind: 'channel_inbound'
104
+ adapter: string
105
+ workspace: string
106
+ chat: string
107
+ thread: string | null
108
+ authorId: string
109
+ authorName: string
110
+ authorIsBot: boolean
111
+ isDm: boolean
112
+ isBotMention: boolean
113
+ text: string
114
+ externalMessageId: string
115
+ // 0 = platform timestamp unknown; the renderer uses the frame's
116
+ // wall-clock ts instead.
117
+ ts: number
118
+ decision: 'engage' | 'observe' | 'denied' | 'claim'
119
+ }
90
120
 
91
121
  export type InspectServerMessage =
92
122
  | { type: 'subscribed'; sessionId: string; sessionLive: boolean }