typeclaw 0.8.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.
- package/README.md +6 -6
- package/package.json +5 -3
- package/scripts/require-parallel.ts +41 -0
- package/src/agent/index.ts +55 -6
- package/src/agent/live-sessions.ts +34 -0
- package/src/agent/plugin-tools.ts +2 -0
- package/src/agent/session-meta.ts +21 -2
- package/src/agent/subagent-completion-reminder.ts +89 -0
- package/src/agent/subagents.ts +3 -2
- package/src/agent/system-prompt.ts +10 -8
- package/src/bundled-plugins/explorer/explorer.ts +2 -2
- package/src/bundled-plugins/guard/index.ts +14 -1
- package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
- package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
- package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
- package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
- package/src/bundled-plugins/guard/policy.ts +7 -0
- package/src/bundled-plugins/memory/README.md +76 -62
- package/src/bundled-plugins/memory/append-tool.ts +3 -2
- package/src/bundled-plugins/memory/citation-superset.ts +49 -11
- package/src/bundled-plugins/memory/citations.ts +19 -8
- package/src/bundled-plugins/memory/delete-tool.ts +57 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
- package/src/bundled-plugins/memory/dreaming.ts +364 -146
- package/src/bundled-plugins/memory/frontmatter.ts +165 -0
- package/src/bundled-plugins/memory/index.ts +236 -16
- package/src/bundled-plugins/memory/injection-plan.ts +15 -0
- package/src/bundled-plugins/memory/load-memory.ts +102 -103
- package/src/bundled-plugins/memory/load-shards.ts +156 -0
- package/src/bundled-plugins/memory/memory-logger.ts +16 -15
- package/src/bundled-plugins/memory/memory-retrieval.ts +105 -0
- package/src/bundled-plugins/memory/migration.ts +282 -1
- package/src/bundled-plugins/memory/paths.ts +42 -0
- package/src/bundled-plugins/memory/search-tool.ts +232 -0
- package/src/bundled-plugins/memory/secret-detector.ts +2 -2
- package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
- package/src/bundled-plugins/memory/slug.ts +59 -0
- package/src/bundled-plugins/memory/stream-io.ts +110 -1
- package/src/bundled-plugins/memory/strength.ts +3 -3
- package/src/bundled-plugins/memory/topics.ts +70 -16
- package/src/bundled-plugins/security/index.ts +24 -0
- package/src/bundled-plugins/security/permissions.ts +4 -0
- package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
- package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
- package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
- package/src/channels/adapters/kakaotalk.ts +64 -37
- package/src/channels/adapters/slack-bot-classify.ts +2 -27
- package/src/channels/index.ts +5 -0
- package/src/channels/router.ts +201 -17
- package/src/channels/subagent-completion-bridge.ts +84 -0
- package/src/cli/builtins.ts +1 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/init.ts +122 -14
- package/src/cli/inspect.ts +151 -0
- package/src/cron/consumer.ts +1 -1
- package/src/init/dockerfile.ts +268 -4
- package/src/init/hatching.ts +5 -6
- package/src/init/kakaotalk-auth.ts +6 -47
- package/src/init/validate-api-key.ts +121 -0
- package/src/inspect/index.ts +213 -0
- package/src/inspect/label.ts +50 -0
- package/src/inspect/live.ts +221 -0
- package/src/inspect/render.ts +163 -0
- package/src/inspect/replay.ts +265 -0
- package/src/inspect/session-list.ts +160 -0
- package/src/inspect/types.ts +110 -0
- package/src/plugin/hooks.ts +23 -1
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +1 -1
- package/src/plugin/registry.ts +1 -1
- package/src/plugin/types.ts +10 -0
- package/src/run/channel-session-factory.ts +7 -1
- package/src/run/index.ts +87 -21
- package/src/secrets/kakao-renewal.ts +3 -47
- package/src/server/index.ts +241 -60
- package/src/shared/index.ts +3 -0
- package/src/shared/protocol.ts +49 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
- package/src/skills/typeclaw-claude-code/SKILL.md +57 -39
- package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
- package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
- package/src/skills/typeclaw-config/SKILL.md +1 -1
- package/src/skills/typeclaw-cron/SKILL.md +1 -1
- package/src/skills/typeclaw-memory/SKILL.md +16 -163
- package/src/skills/typeclaw-permissions/SKILL.md +2 -2
- package/src/skills/typeclaw-plugins/SKILL.md +25 -14
- package/src/test-helpers/wait-for.ts +7 -1
- package/typeclaw.schema.json +7 -0
package/src/server/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
755
|
-
if (
|
|
756
|
-
|
|
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 =
|
|
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
|
|
874
|
+
const turnEndEvent = { sessionId: state.sessionFileId, agentDir, origin: state.origin }
|
|
870
875
|
return {
|
|
871
|
-
fireTurnStart: () =>
|
|
872
|
-
|
|
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,
|
package/src/shared/index.ts
CHANGED
package/src/shared/protocol.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 (
|
|
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.
|