typeclaw 0.7.0 → 0.9.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 (107) hide show
  1. package/README.md +15 -9
  2. package/package.json +5 -3
  3. package/scripts/dump-system-prompt.ts +12 -1
  4. package/scripts/require-parallel.ts +41 -0
  5. package/src/agent/auth.ts +3 -3
  6. package/src/agent/index.ts +116 -14
  7. package/src/agent/live-sessions.ts +34 -0
  8. package/src/agent/multimodal/read-redirect.ts +43 -0
  9. package/src/agent/plugin-tools.ts +97 -13
  10. package/src/agent/session-meta.ts +21 -2
  11. package/src/agent/session-origin.ts +6 -13
  12. package/src/agent/subagent-completion-reminder.ts +89 -0
  13. package/src/agent/subagents.ts +3 -2
  14. package/src/agent/system-prompt.ts +49 -15
  15. package/src/bundled-plugins/explorer/explorer.ts +2 -2
  16. package/src/bundled-plugins/guard/index.ts +14 -1
  17. package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
  18. package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
  19. package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
  20. package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
  21. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
  22. package/src/bundled-plugins/guard/policy.ts +7 -0
  23. package/src/bundled-plugins/memory/README.md +76 -62
  24. package/src/bundled-plugins/memory/append-tool.ts +3 -2
  25. package/src/bundled-plugins/memory/citation-superset.ts +49 -11
  26. package/src/bundled-plugins/memory/citations.ts +19 -8
  27. package/src/bundled-plugins/memory/delete-tool.ts +57 -0
  28. package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
  29. package/src/bundled-plugins/memory/dreaming.ts +364 -146
  30. package/src/bundled-plugins/memory/frontmatter.ts +165 -0
  31. package/src/bundled-plugins/memory/index.ts +236 -16
  32. package/src/bundled-plugins/memory/injection-plan.ts +15 -0
  33. package/src/bundled-plugins/memory/load-memory.ts +102 -103
  34. package/src/bundled-plugins/memory/load-shards.ts +156 -0
  35. package/src/bundled-plugins/memory/memory-logger.ts +16 -15
  36. package/src/bundled-plugins/memory/memory-retrieval.ts +105 -0
  37. package/src/bundled-plugins/memory/migration.ts +282 -1
  38. package/src/bundled-plugins/memory/paths.ts +42 -0
  39. package/src/bundled-plugins/memory/search-tool.ts +232 -0
  40. package/src/bundled-plugins/memory/secret-detector.ts +2 -2
  41. package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
  42. package/src/bundled-plugins/memory/slug.ts +59 -0
  43. package/src/bundled-plugins/memory/stream-io.ts +110 -1
  44. package/src/bundled-plugins/memory/strength.ts +3 -3
  45. package/src/bundled-plugins/memory/topics.ts +70 -16
  46. package/src/bundled-plugins/security/index.ts +24 -0
  47. package/src/bundled-plugins/security/permissions.ts +4 -0
  48. package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
  49. package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
  50. package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
  51. package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
  52. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
  53. package/src/channels/adapters/discord-bot-slash-commands.ts +186 -0
  54. package/src/channels/adapters/discord-bot.ts +163 -1
  55. package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
  56. package/src/channels/adapters/kakaotalk.ts +64 -37
  57. package/src/channels/adapters/slack-bot-classify.ts +2 -27
  58. package/src/channels/adapters/slack-bot-slash-commands.ts +82 -0
  59. package/src/channels/adapters/slack-bot.ts +139 -1
  60. package/src/channels/index.ts +5 -0
  61. package/src/channels/router.ts +328 -18
  62. package/src/channels/subagent-completion-bridge.ts +84 -0
  63. package/src/cli/builtins.ts +1 -0
  64. package/src/cli/index.ts +1 -0
  65. package/src/cli/init.ts +122 -14
  66. package/src/cli/inspect.ts +151 -0
  67. package/src/cli/role.ts +7 -2
  68. package/src/cli/tunnel.ts +13 -1
  69. package/src/cli/ui.ts +25 -1
  70. package/src/config/index.ts +1 -0
  71. package/src/config/models-mutation.ts +10 -2
  72. package/src/cron/consumer.ts +1 -1
  73. package/src/init/dockerfile.ts +353 -2
  74. package/src/init/hatching.ts +5 -6
  75. package/src/init/kakaotalk-auth.ts +6 -47
  76. package/src/init/validate-api-key.ts +121 -0
  77. package/src/inspect/index.ts +213 -0
  78. package/src/inspect/label.ts +50 -0
  79. package/src/inspect/live.ts +221 -0
  80. package/src/inspect/render.ts +163 -0
  81. package/src/inspect/replay.ts +265 -0
  82. package/src/inspect/session-list.ts +160 -0
  83. package/src/inspect/types.ts +110 -0
  84. package/src/plugin/hooks.ts +23 -1
  85. package/src/plugin/index.ts +2 -0
  86. package/src/plugin/manager.ts +1 -1
  87. package/src/plugin/registry.ts +1 -1
  88. package/src/plugin/types.ts +10 -0
  89. package/src/run/channel-session-factory.ts +7 -1
  90. package/src/run/index.ts +87 -21
  91. package/src/secrets/kakao-renewal.ts +3 -47
  92. package/src/server/index.ts +241 -60
  93. package/src/shared/index.ts +4 -1
  94. package/src/shared/local-time.ts +17 -0
  95. package/src/shared/protocol.ts +49 -0
  96. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
  97. package/src/skills/typeclaw-claude-code/SKILL.md +83 -40
  98. package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
  99. package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
  100. package/src/skills/typeclaw-config/SKILL.md +38 -33
  101. package/src/skills/typeclaw-cron/SKILL.md +1 -1
  102. package/src/skills/typeclaw-git/SKILL.md +2 -2
  103. package/src/skills/typeclaw-memory/SKILL.md +16 -163
  104. package/src/skills/typeclaw-permissions/SKILL.md +2 -2
  105. package/src/skills/typeclaw-plugins/SKILL.md +26 -15
  106. package/src/test-helpers/wait-for.ts +7 -1
  107. package/typeclaw.schema.json +7 -0
@@ -7,9 +7,11 @@ import {
7
7
  type CreateSessionResult,
8
8
  } from '@/agent'
9
9
  import { runPluginDoctorChecks, runPluginDoctorFix } from '@/agent/doctor'
10
+ import type { LiveSessionRegistry } from '@/agent/live-sessions'
10
11
  import type { LiveSubagentRegistry } from '@/agent/live-subagents'
11
12
  import { detectProviderError } from '@/agent/provider-error'
12
13
  import type { SessionOrigin } from '@/agent/session-origin'
14
+ import { parseSubagentCompletedPayload, renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
13
15
  import type { CreateSessionForSubagent } from '@/agent/subagents'
14
16
  import type { ChannelRouter } from '@/channels/router'
15
17
  import { aggregateCronList, type CronListEntry, loadCron } from '@/cron'
@@ -24,6 +26,9 @@ import type {
24
26
  ClientMessage,
25
27
  CronListEntryPayload,
26
28
  CronListSourcePayload,
29
+ InspectClientMessage,
30
+ InspectFramePayload,
31
+ InspectServerMessage,
27
32
  PromptDelivery,
28
33
  QueueStateItem,
29
34
  ReloadResultPayload,
@@ -102,6 +107,13 @@ export type ServerOptions = {
102
107
  // as out-of-scope follow-up.
103
108
  liveSubagentRegistry?: LiveSubagentRegistry
104
109
  createSessionForSubagent?: CreateSessionForSubagent
110
+ // Id-keyed registry of live AgentSessions, used by `/inspect` (and any
111
+ // future read-only session-event consumer) to subscribe to session
112
+ // events without owning the session lifecycle. Populated by every
113
+ // session-creation site that wants its sessions inspectable; an absent
114
+ // registry is fine — `/inspect` will report "session not live, replay
115
+ // from JSONL only" when it can't resolve the id.
116
+ liveSessionRegistry?: LiveSessionRegistry
105
117
  }
106
118
 
107
119
  const consoleLogger: ServerLogger = {
@@ -119,10 +131,17 @@ type TuiWsData = { kind: 'tui'; sessionId: string }
119
131
  // TUI token can already do anything the TUI can).
120
132
  type CommandWsData = { kind: 'command' }
121
133
  type TunnelLogsWsData = { kind: 'tunnel-logs'; unsubscribe: Unsubscribe | null }
122
- type WsData = TuiWsData | CommandWsData | TunnelLogsWsData | BrokerWsData
134
+ type InspectWsData = {
135
+ kind: 'inspect'
136
+ unsubAgent: (() => void) | null
137
+ unsubBroadcast: Unsubscribe | null
138
+ unsubCron: Unsubscribe | null
139
+ }
140
+ type WsData = TuiWsData | CommandWsData | TunnelLogsWsData | BrokerWsData | InspectWsData
123
141
  type Ws = ServerWebSocket<TuiWsData>
124
142
  type CommandWs = ServerWebSocket<CommandWsData>
125
143
  type TunnelLogsWs = ServerWebSocket<TunnelLogsWsData>
144
+ type InspectWs = ServerWebSocket<InspectWsData>
126
145
  type AnyOwnerWs = Ws | CommandWs
127
146
 
128
147
  type QueuedPrompt = {
@@ -175,6 +194,15 @@ function sendTunnelLog(ws: TunnelLogsWs, msg: TunnelLogsServerMessage): boolean
175
194
  }
176
195
  }
177
196
 
197
+ function sendInspect(ws: InspectWs, msg: InspectServerMessage): boolean {
198
+ try {
199
+ ws.send(JSON.stringify(msg))
200
+ return true
201
+ } catch {
202
+ return false
203
+ }
204
+ }
205
+
178
206
  function encodeBase64(bytes: Uint8Array): string {
179
207
  let s = ''
180
208
  for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i] ?? 0)
@@ -201,6 +229,7 @@ export function createServer({
201
229
  commandRunnerFactory,
202
230
  liveSubagentRegistry,
203
231
  createSessionForSubagent,
232
+ liveSessionRegistry,
204
233
  }: ServerOptions) {
205
234
  const sessionStates = new WeakMap<Ws, SessionState>()
206
235
  const callIdToWs = new Map<string, AnyOwnerWs>()
@@ -318,6 +347,14 @@ export function createServer({
318
347
  if (server.upgrade(req, { data })) return
319
348
  return new Response('upgrade failed', { status: 400 })
320
349
  }
350
+ if (url.pathname === '/inspect') {
351
+ if (isWebSocketUpgrade(req) && tuiToken !== undefined && url.searchParams.get('token') !== tuiToken) {
352
+ return new Response('unauthorized', { status: 401 })
353
+ }
354
+ const data: InspectWsData = { kind: 'inspect', unsubAgent: null, unsubBroadcast: null, unsubCron: null }
355
+ if (server.upgrade(req, { data })) return
356
+ return new Response('upgrade failed', { status: 400 })
357
+ }
321
358
  // `/commands` is the dedicated host-CLI proxy path. It skips TUI
322
359
  // session creation (which costs an AgentSession spawn per command
323
360
  // invocation) but uses the same tuiToken because both surfaces
@@ -357,6 +394,7 @@ export function createServer({
357
394
  return
358
395
  }
359
396
  if (rawWs.data.kind === 'tunnel-logs') return
397
+ if (rawWs.data.kind === 'inspect') return
360
398
  const ws = rawWs as Ws
361
399
  try {
362
400
  const sessionManager = sessionFactory?.createPersisted()
@@ -421,6 +459,7 @@ export function createServer({
421
459
  await runtimeSnapshot.hooks.runSessionStart({ sessionId: sessionFileId, agentDir })
422
460
  }
423
461
 
462
+ liveSessionRegistry?.register({ sessionId: sessionFileId, session })
424
463
  forwardSessionEvents(ws, session, logger, sessionFileId)
425
464
 
426
465
  if (stream) {
@@ -474,6 +513,10 @@ export function createServer({
474
513
  handleTunnelLogsMessage(rawWs as TunnelLogsWs, raw, tunnelManager)
475
514
  return
476
515
  }
516
+ if (rawWs.data.kind === 'inspect') {
517
+ handleInspectMessage(rawWs as InspectWs, raw, liveSessionRegistry, stream)
518
+ return
519
+ }
477
520
  const ws = rawWs as Ws
478
521
  const msg = JSON.parse(String(raw)) as ClientMessage
479
522
  const state = sessionStates.get(ws)
@@ -605,6 +648,7 @@ export function createServer({
605
648
  await fallbackHooks.runSessionTurnStart({
606
649
  sessionId: state.sessionFileId,
607
650
  agentDir,
651
+ userPrompt: msg.text,
608
652
  origin: state.origin,
609
653
  })
610
654
  }
@@ -657,6 +701,16 @@ export function createServer({
657
701
  rawWs.data.unsubscribe = null
658
702
  return
659
703
  }
704
+ if (rawWs.data.kind === 'inspect') {
705
+ const d = rawWs.data
706
+ d.unsubAgent?.()
707
+ d.unsubBroadcast?.()
708
+ d.unsubCron?.()
709
+ d.unsubAgent = null
710
+ d.unsubBroadcast = null
711
+ d.unsubCron = null
712
+ return
713
+ }
660
714
  const ws = rawWs as Ws
661
715
  const state = sessionStates.get(ws)
662
716
  state?.unsubBroadcast?.()
@@ -677,6 +731,7 @@ export function createServer({
677
731
  if (state) {
678
732
  state.session.dispose()
679
733
  await state.dispose()
734
+ liveSessionRegistry?.unregister(state.sessionFileId)
680
735
  }
681
736
  sessionStates.delete(ws)
682
737
  console.log(`session ${state?.sessionFileId ?? ws.data.sessionId}: close`)
@@ -751,29 +806,13 @@ function forwardAssistantError(ws: Ws, message: unknown, logger: ServerLogger, s
751
806
  }
752
807
 
753
808
  function routeSubagentCompletionReminder(state: SessionState, msg: StreamMessage, stream: Stream): void {
754
- const payload = msg.payload
755
- if (payload === null || typeof payload !== 'object') return
756
- const p = payload as {
757
- kind?: unknown
758
- taskId?: unknown
759
- subagent?: unknown
760
- parentSessionId?: unknown
761
- ok?: unknown
762
- durationMs?: unknown
763
- error?: unknown
764
- }
765
- if (p.kind !== 'subagent.completed') return
766
- if (typeof p.parentSessionId !== 'string' || p.parentSessionId !== state.sessionFileId) return
767
-
768
- const subagent = typeof p.subagent === 'string' ? p.subagent : 'subagent'
769
- const taskId = typeof p.taskId === 'string' ? p.taskId : '<unknown>'
770
- const ok = p.ok === true
771
- const durationMs = typeof p.durationMs === 'number' ? p.durationMs : 0
772
- const error = typeof p.error === 'string' ? p.error : undefined
809
+ const parsed = parseSubagentCompletedPayload(msg.payload)
810
+ if (parsed === null) return
811
+ if (parsed.parentSessionId !== state.sessionFileId) return
773
812
 
774
813
  const idle = state.drainQueue.length === 0 && !state.draining
775
814
  const delivery = idle ? 'interrupt' : 'queue'
776
- const text = renderCompletionReminder({ subagent, taskId, ok, durationMs, error })
815
+ const text = renderSubagentCompletionReminder(parsed)
777
816
  stream.publish({
778
817
  target: { kind: 'session', sessionId: state.sessionFileId },
779
818
  payload: { kind: 'prompt', text, delivery },
@@ -781,40 +820,6 @@ function routeSubagentCompletionReminder(state: SessionState, msg: StreamMessage
781
820
  })
782
821
  }
783
822
 
784
- function renderCompletionReminder(args: {
785
- subagent: string
786
- taskId: string
787
- ok: boolean
788
- durationMs: number
789
- error?: string
790
- }): string {
791
- const durationStr = formatReminderDuration(args.durationMs)
792
- if (args.ok) {
793
- return (
794
- `<system-reminder>\n` +
795
- `Subagent \`${args.subagent}\` (${args.taskId}) completed in ${durationStr}. ` +
796
- `Use subagent_output to fetch the result.\n` +
797
- `</system-reminder>`
798
- )
799
- }
800
- const err = args.error ?? 'unknown error'
801
- return (
802
- `<system-reminder>\n` +
803
- `Subagent \`${args.subagent}\` (${args.taskId}) FAILED after ${durationStr}: ${err}. ` +
804
- `Use subagent_output to inspect.\n` +
805
- `</system-reminder>`
806
- )
807
- }
808
-
809
- function formatReminderDuration(ms: number): string {
810
- if (ms < 1000) return `${ms}ms`
811
- const totalSec = Math.floor(ms / 1000)
812
- if (totalSec < 60) return `${totalSec}s`
813
- const min = Math.floor(totalSec / 60)
814
- const sec = totalSec % 60
815
- return `${min}m${sec}s`
816
- }
817
-
818
823
  function enqueuePrompt(
819
824
  ws: Ws,
820
825
  state: SessionState,
@@ -861,15 +866,16 @@ function makeIdleHookCaller(state: SessionState): () => Promise<void> {
861
866
  function makeTurnHookCallers(
862
867
  state: SessionState,
863
868
  agentDir: string | undefined,
864
- ): { fireTurnStart: () => Promise<void>; fireTurnEnd: () => Promise<void> } {
869
+ ): { fireTurnStart: (userPrompt: string) => Promise<void>; fireTurnEnd: () => Promise<void> } {
865
870
  const hooks: HookBus | undefined = state.runtimeSnapshot?.hooks
866
871
  if (hooks === undefined || agentDir === undefined) {
867
872
  return { fireTurnStart: async () => {}, fireTurnEnd: async () => {} }
868
873
  }
869
- const event = { sessionId: state.sessionFileId, agentDir, origin: state.origin }
874
+ const turnEndEvent = { sessionId: state.sessionFileId, agentDir, origin: state.origin }
870
875
  return {
871
- fireTurnStart: () => hooks.runSessionTurnStart(event),
872
- fireTurnEnd: () => hooks.runSessionTurnEnd(event),
876
+ fireTurnStart: (userPrompt) =>
877
+ hooks.runSessionTurnStart({ sessionId: state.sessionFileId, agentDir, userPrompt, origin: state.origin }),
878
+ fireTurnEnd: () => hooks.runSessionTurnEnd(turnEndEvent),
873
879
  }
874
880
  }
875
881
 
@@ -885,7 +891,7 @@ async function drain(ws: Ws, state: SessionState, agentDir: string | undefined,
885
891
  pushQueueState(ws, state)
886
892
  send(ws, { type: 'prompt_started', messageId: item.streamMessageId, text: item.text })
887
893
 
888
- await fireTurnStart()
894
+ await fireTurnStart(item.text)
889
895
  try {
890
896
  await state.session.prompt(item.text)
891
897
  send(ws, { type: 'done' })
@@ -1030,6 +1036,181 @@ function handleTunnelStatus(ws: Ws, requestId: string, name: string, tunnelManag
1030
1036
  send(ws, { type: 'tunnel_status_response', requestId, ok: true, tunnel })
1031
1037
  }
1032
1038
 
1039
+ function handleInspectMessage(
1040
+ ws: InspectWs,
1041
+ raw: string | Buffer,
1042
+ liveSessionRegistry: LiveSessionRegistry | undefined,
1043
+ stream: Stream | undefined,
1044
+ ): void {
1045
+ let msg: InspectClientMessage
1046
+ try {
1047
+ msg = JSON.parse(String(raw)) as InspectClientMessage
1048
+ } catch {
1049
+ sendInspect(ws, { type: 'error', message: 'invalid JSON' })
1050
+ ws.close()
1051
+ return
1052
+ }
1053
+ if (msg.type !== 'subscribe' || typeof msg.sessionId !== 'string' || msg.sessionId === '') {
1054
+ sendInspect(ws, { type: 'error', message: 'invalid inspect subscription' })
1055
+ ws.close()
1056
+ return
1057
+ }
1058
+
1059
+ ws.data.unsubAgent?.()
1060
+ ws.data.unsubBroadcast?.()
1061
+ ws.data.unsubCron?.()
1062
+
1063
+ if (stream !== undefined && typeof msg.sinceMs === 'number') {
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
+ })
1074
+ }
1075
+ for (const event of stream.scan({ sinceTs: msg.sinceMs, target: { kind: 'cron' } })) {
1076
+ sendInspect(ws, {
1077
+ type: 'frame',
1078
+ ts: event.ts,
1079
+ payload: { kind: 'cron-fire', jobId: extractJobId(event.target), payload: event.payload },
1080
+ })
1081
+ }
1082
+ }
1083
+
1084
+ const live = liveSessionRegistry?.get(msg.sessionId)
1085
+ if (live !== undefined) {
1086
+ const sessionId = msg.sessionId
1087
+ const startedAtByCallId = new Map<string, number>()
1088
+ ws.data.unsubAgent = live.session.subscribe((event: unknown) => {
1089
+ forwardAgentEventToInspect(ws, event, sessionId, startedAtByCallId)
1090
+ })
1091
+ }
1092
+
1093
+ if (stream !== undefined) {
1094
+ 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
+ })
1104
+ })
1105
+ ws.data.unsubCron = stream.subscribe({ target: { kind: 'cron' } }, (event) => {
1106
+ sendInspect(ws, {
1107
+ type: 'frame',
1108
+ ts: event.ts,
1109
+ payload: { kind: 'cron-fire', jobId: extractJobId(event.target), payload: event.payload },
1110
+ })
1111
+ })
1112
+ }
1113
+
1114
+ sendInspect(ws, { type: 'subscribed', sessionId: msg.sessionId, sessionLive: live !== undefined })
1115
+ }
1116
+
1117
+ function extractJobId(target: StreamMessage['target']): string {
1118
+ return target.kind === 'cron' ? target.jobId : ''
1119
+ }
1120
+
1121
+ function forwardAgentEventToInspect(
1122
+ ws: InspectWs,
1123
+ event: unknown,
1124
+ sessionId: string,
1125
+ startedAtByCallId: Map<string, number>,
1126
+ ): void {
1127
+ if (typeof event !== 'object' || event === null) return
1128
+ const e = event as { type?: unknown }
1129
+ const now = Date.now()
1130
+ if (e.type === 'message_update') {
1131
+ const ev = event as { assistantMessageEvent?: { type?: unknown; delta?: unknown } }
1132
+ const ame = ev.assistantMessageEvent
1133
+ if (ame?.type === 'text_delta' && typeof ame.delta === 'string') {
1134
+ sendInspect(ws, { type: 'frame', ts: now, payload: { kind: 'text_delta', sessionId, delta: ame.delta } })
1135
+ }
1136
+ return
1137
+ }
1138
+ if (e.type === 'tool_execution_start') {
1139
+ const ev = event as { toolCallId?: unknown; toolName?: unknown; args?: unknown }
1140
+ if (typeof ev.toolCallId !== 'string' || typeof ev.toolName !== 'string') return
1141
+ startedAtByCallId.set(ev.toolCallId, now)
1142
+ sendInspect(ws, {
1143
+ type: 'frame',
1144
+ ts: now,
1145
+ payload: { kind: 'tool_start', sessionId, toolCallId: ev.toolCallId, name: ev.toolName, args: ev.args },
1146
+ })
1147
+ return
1148
+ }
1149
+ if (e.type === 'tool_execution_end') {
1150
+ const ev = event as { toolCallId?: unknown; toolName?: unknown; result?: unknown; isError?: unknown }
1151
+ if (typeof ev.toolCallId !== 'string' || typeof ev.toolName !== 'string') return
1152
+ const startedAt = startedAtByCallId.get(ev.toolCallId)
1153
+ startedAtByCallId.delete(ev.toolCallId)
1154
+ const durationMs = startedAt === undefined ? 0 : now - startedAt
1155
+ sendInspect(ws, {
1156
+ type: 'frame',
1157
+ ts: now,
1158
+ payload: {
1159
+ kind: 'tool_end',
1160
+ sessionId,
1161
+ toolCallId: ev.toolCallId,
1162
+ name: ev.toolName,
1163
+ result: ev.result,
1164
+ isError: ev.isError === true,
1165
+ durationMs,
1166
+ },
1167
+ })
1168
+ return
1169
+ }
1170
+ if (e.type === 'message_end') {
1171
+ const ev = event as { message?: unknown }
1172
+ const payload = buildMessageEndPayload(sessionId, ev.message)
1173
+ if (payload !== null) sendInspect(ws, { type: 'frame', ts: now, payload })
1174
+ return
1175
+ }
1176
+ }
1177
+
1178
+ function buildMessageEndPayload(sessionId: string, message: unknown): InspectFramePayload | null {
1179
+ if (typeof message !== 'object' || message === null) return null
1180
+ const m = message as Record<string, unknown>
1181
+ if (typeof m.role !== 'string') return null
1182
+ const usage = readMessageUsage(m.usage)
1183
+ const payload: InspectFramePayload = {
1184
+ kind: 'message_end',
1185
+ sessionId,
1186
+ role: m.role,
1187
+ content: m.content,
1188
+ ...(typeof m.provider === 'string' ? { provider: m.provider } : {}),
1189
+ ...(typeof m.model === 'string' ? { model: m.model } : {}),
1190
+ ...(typeof m.stopReason === 'string' ? { stopReason: m.stopReason } : {}),
1191
+ ...(typeof m.errorMessage === 'string' ? { errorMessage: m.errorMessage } : {}),
1192
+ ...(usage !== null ? { usage } : {}),
1193
+ }
1194
+ return payload
1195
+ }
1196
+
1197
+ function readMessageUsage(
1198
+ value: unknown,
1199
+ ): { input: number; output: number; cacheRead: number; cacheWrite: number; totalTokens: number; cost: number } | null {
1200
+ if (typeof value !== 'object' || value === null) return null
1201
+ const u = value as Record<string, unknown>
1202
+ const cost = u.cost as Record<string, unknown> | undefined
1203
+ const numberOr = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
1204
+ return {
1205
+ input: numberOr(u.input),
1206
+ output: numberOr(u.output),
1207
+ cacheRead: numberOr(u.cacheRead),
1208
+ cacheWrite: numberOr(u.cacheWrite),
1209
+ totalTokens: numberOr(u.totalTokens),
1210
+ cost: numberOr(cost?.total),
1211
+ }
1212
+ }
1213
+
1033
1214
  function handleTunnelLogsMessage(
1034
1215
  ws: TunnelLogsWs,
1035
1216
  raw: string | Buffer,
@@ -11,6 +11,9 @@ export {
11
11
  type DoctorCheckPayload,
12
12
  type DoctorFixPayload,
13
13
  type DoctorRequestId,
14
+ type InspectClientMessage,
15
+ type InspectFramePayload,
16
+ type InspectServerMessage,
14
17
  type PromptDelivery,
15
18
  type QueueStateItem,
16
19
  type ReloadResultPayload,
@@ -21,4 +24,4 @@ export {
21
24
  type TunnelSnapshot,
22
25
  } from './protocol'
23
26
 
24
- export { formatLocalDate, formatLocalDateTime } from './local-time'
27
+ export { formatLocalDate, formatLocalDateTime, resolveLocalTimezoneName } from './local-time'
@@ -19,3 +19,20 @@ function formatTimezoneOffset(date: Date): string {
19
19
  const abs = Math.abs(offsetMinutes)
20
20
  return `${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`
21
21
  }
22
+
23
+ // IANA timezone name of the process (e.g. `Asia/Seoul`). Reads the resolved
24
+ // zone from Intl, falling back to `UTC` if the runtime cannot resolve one —
25
+ // this should never happen on Bun + tzdata-equipped containers, but the
26
+ // fallback keeps the prompt renderable rather than throwing during session
27
+ // creation. The returned name is what the agent shows the user when asked
28
+ // "what time is it" — pairing the wall clock with a recognizable zone name
29
+ // is what disambiguates "15:31 +09:00" from "15:31 KST" for a non-technical
30
+ // reader.
31
+ export function resolveLocalTimezoneName(): string {
32
+ try {
33
+ const zone = Intl.DateTimeFormat().resolvedOptions().timeZone
34
+ return zone && zone.length > 0 ? zone : 'UTC'
35
+ } catch {
36
+ return 'UTC'
37
+ }
38
+ }
@@ -44,6 +44,55 @@ export type TunnelLogsServerMessage =
44
44
  | { type: 'error'; message: string }
45
45
  | { type: 'end' }
46
46
 
47
+ export type InspectClientMessage = {
48
+ type: 'subscribe'
49
+ sessionId: string
50
+ // sinceMs is a wall-clock cutoff for backfilling broadcasts from the
51
+ // in-process Stream ring buffer. The client uses Date.now() - duration;
52
+ // omit to skip broadcast backfill. AgentSession events are NEVER
53
+ // backfilled (the session's pi-coding-agent subscribe API delivers
54
+ // future events only).
55
+ sinceMs?: number
56
+ }
57
+
58
+ export type InspectFramePayload =
59
+ | { kind: 'text_delta'; sessionId: string; delta: string }
60
+ | { kind: 'tool_start'; sessionId: string; toolCallId: string; name: string; args: unknown }
61
+ | {
62
+ kind: 'tool_end'
63
+ sessionId: string
64
+ toolCallId: string
65
+ name: string
66
+ result: unknown
67
+ isError: boolean
68
+ durationMs: number
69
+ }
70
+ | {
71
+ kind: 'message_end'
72
+ sessionId: string
73
+ role: string
74
+ content: unknown
75
+ provider?: string
76
+ model?: string
77
+ stopReason?: string
78
+ errorMessage?: string
79
+ usage?: {
80
+ input: number
81
+ output: number
82
+ cacheRead: number
83
+ cacheWrite: number
84
+ totalTokens: number
85
+ cost: number
86
+ }
87
+ }
88
+ | { kind: 'broadcast'; payload: unknown; meta?: Record<string, string> }
89
+ | { kind: 'cron-fire'; jobId: string; payload: unknown }
90
+
91
+ export type InspectServerMessage =
92
+ | { type: 'subscribed'; sessionId: string; sessionLive: boolean }
93
+ | { type: 'frame'; ts: number; payload: InspectFramePayload }
94
+ | { type: 'error'; message: string }
95
+
47
96
  export type ClientMessage =
48
97
  | { type: 'prompt'; text: string; delivery?: PromptDelivery }
49
98
  | { type: 'reload'; scope?: string }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: typeclaw-channel-kakaotalk
3
- description: Use this skill BEFORE every `channel_reply` or `channel_send` call whose adapter is `kakaotalk`, AND before calling `channel_fetch_attachment` against a KakaoTalk URL. KakaoTalk renders messages as plain text — `**bold**`, `## headings`, `| tables |`, fenced code blocks, and other markdown all appear literally. There is no `@mention` syntax, no message threads, no replies-with-quote, and no outbound file attachments or stickers. Inbound photos / files / video / audio CAN be downloaded via `channel_fetch_attachment` (the placeholder text includes the URL); inbound stickers are metadata-only and cannot be fetched. URLs expire ~3 days after the message arrives. Read this skill before composing or fetching anything on KakaoTalk.
3
+ description: Use this skill BEFORE every `channel_reply` or `channel_send` call whose adapter is `kakaotalk`, AND before calling `channel_fetch_attachment` against a KakaoTalk URL. KakaoTalk renders messages as plain text — `**bold**`, `## headings`, `| tables |`, fenced code blocks, and other markdown all appear literally. There is no `@mention` syntax, no message threads, no replies-with-quote, and no outbound stickers. Outbound file attachments (photos, videos, audio, generic files, multi-photo galleries) ARE supported — pass them via `attachments[]` on `channel_send` / `channel_reply` and the adapter routes by MIME. Inbound photos / files / video / audio CAN be downloaded via `channel_fetch_attachment` (the placeholder text includes the URL); inbound stickers are metadata-only and cannot be fetched. URLs expire ~3 days after the message arrives. Read this skill before composing or fetching anything on KakaoTalk.
4
4
  ---
5
5
 
6
6
  # typeclaw-channel-kakaotalk
@@ -21,15 +21,19 @@ If you produce any of the following, KakaoTalk will render it literally and the
21
21
  - **Links with display text** — `[label](url)` becomes the literal string. Send the bare URL on its own; the KakaoTalk client will auto-link it.
22
22
  - **Mentions** — there is no `@user` syntax that the protocol surfaces. Address people by name in the message body.
23
23
  - **Threads / replies-with-quote** — every message is a top-level chat post. There is no per-message reply UI.
24
- - **Outbound attachments / stickers** — agent-messenger's KakaoTalk SDK exposes no upload API. The adapter is outbound text-only. If the user asks you to send a file or sticker, say so and offer an alternative (paste a link, summarize the file, ship it via another channel).
25
-
26
- The adapter rejects outbound attachments via `ok: false` rather than partially sending the text — the agent contract is "ok=true means the whole request succeeded", so a silent drop would let you confidently report "I sent your file" when the file never arrived.
24
+ - **Outbound stickers / emoticons** — the KakaoTalk sticker store requires desktop-app purchase flows that the SDK does not replicate. Inbound stickers ARE surfaced (see below), but you cannot send one. If the user asks for a sticker, acknowledge the limit and offer text.
27
25
 
28
26
  ## What KakaoTalk DOES support
29
27
 
30
28
  - Plain UTF-8 text. Emoji are fine.
31
29
  - URLs auto-linkify in the client. Send them bare — `https://example.com/foo`, no markdown wrapping.
32
30
  - Newlines render as line breaks. You can use `\n\n` to space paragraphs.
31
+ - **Outbound file attachments** — photos, videos, audio, generic files, and multi-photo galleries. Pass each as an `OutboundAttachment { path, filename? }` on the `attachments[]` field of `channel_send` / `channel_reply`. The adapter sniffs the MIME from the filename and routes to the right KakaoTalk message type, so the caller does not pick photo-vs-file-vs-multiphoto by hand:
32
+ - Single attachment → single send. `image/*` renders inline with tap-to-zoom; `video/*` as an inline player; `audio/*` as a voice bubble; everything else as a downloadable file.
33
+ - Multiple attachments, all image MIMEs → multi-photo gallery (one chat bubble containing every image).
34
+ - Multiple attachments with mixed kinds (e.g. photo + PDF) → individual sends, one bubble each, in array order.
35
+ - Each send is fail-fast: if any upload fails the adapter returns `ok: false` immediately rather than partial-success.
36
+ - **Text + attachments in one `channel_send`** — files upload first, then the text posts as a separate chat bubble. KakaoTalk has no Slack-style `initial_comment` that lets text and files share a single send.
33
37
 
34
38
  ## Inbound attachments and stickers
35
39
 
@@ -103,8 +107,4 @@ The adapter drops every inbound where `event.author_id` equals the logged-in acc
103
107
 
104
108
  ## When you cannot answer in KakaoTalk
105
109
 
106
- If the user asks you to do something the adapter cannot do (send a file, render markdown, post in a thread), say so plainly:
107
-
108
- > "I can't attach files through this chat. Want me to drop the file in [other channel] instead?"
109
-
110
- Better than silently dropping the attachment and pretending you sent it.
110
+ If the user asks you to do something the adapter cannot do (render markdown, post in a thread, send a sticker), say so plainly. Files are fine — those go through `attachments[]` as described above — but markdown rendering, threading, and stickers are real limits. Acknowledge the limit instead of silently dropping the request.