typeclaw 0.22.0 → 0.24.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 (53) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +91 -22
  3. package/src/agent/plugin-tools.ts +38 -2
  4. package/src/agent/restart/index.ts +15 -3
  5. package/src/agent/restart-handoff/index.ts +110 -12
  6. package/src/agent/session-origin.ts +41 -2
  7. package/src/agent/subagent-completion-reminder.ts +3 -1
  8. package/src/agent/subagents.ts +44 -1
  9. package/src/agent/system-prompt.ts +4 -0
  10. package/src/agent/todo/continuation-policy.ts +242 -0
  11. package/src/agent/todo/continuation-state.ts +87 -0
  12. package/src/agent/todo/continuation-wiring.ts +113 -0
  13. package/src/agent/todo/continuation.ts +71 -0
  14. package/src/agent/todo/scope.ts +77 -0
  15. package/src/agent/todo/store.ts +98 -0
  16. package/src/agent/tool-not-found-nudge.ts +119 -0
  17. package/src/agent/tools/channel-reply.ts +51 -0
  18. package/src/agent/tools/restart.ts +11 -4
  19. package/src/agent/tools/todo/index.ts +119 -0
  20. package/src/bundled-plugins/backup/runner.ts +1 -1
  21. package/src/bundled-plugins/memory/memory-logger.ts +28 -10
  22. package/src/bundled-plugins/reviewer/reviewer.ts +14 -0
  23. package/src/channels/adapters/discord-bot-reference.ts +78 -0
  24. package/src/channels/adapters/discord-bot.ts +31 -3
  25. package/src/channels/adapters/github/inbound.ts +161 -10
  26. package/src/channels/adapters/github/index.ts +18 -0
  27. package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
  28. package/src/channels/adapters/kakaotalk-classify.ts +67 -6
  29. package/src/channels/adapters/slack-bot-classify.ts +9 -1
  30. package/src/channels/adapters/slack-bot-reference.ts +129 -0
  31. package/src/channels/adapters/slack-bot.ts +75 -8
  32. package/src/channels/adapters/telegram-bot.ts +11 -0
  33. package/src/channels/manager.ts +8 -2
  34. package/src/channels/router.ts +477 -22
  35. package/src/channels/schema.ts +20 -4
  36. package/src/channels/types.ts +95 -0
  37. package/src/cli/inspect-controller.ts +99 -0
  38. package/src/cli/inspect.ts +21 -123
  39. package/src/commands/index.ts +9 -0
  40. package/src/init/gitignore.ts +5 -2
  41. package/src/inspect/index.ts +30 -26
  42. package/src/inspect/live.ts +17 -3
  43. package/src/inspect/loop.ts +23 -17
  44. package/src/run/index.ts +60 -5
  45. package/src/sandbox/build.ts +10 -0
  46. package/src/sandbox/index.ts +2 -0
  47. package/src/sandbox/policy.ts +10 -0
  48. package/src/sandbox/writable-zones.ts +78 -0
  49. package/src/server/index.ts +118 -4
  50. package/src/skills/typeclaw-channel-github/SKILL.md +34 -7
  51. package/src/skills/typeclaw-config/SKILL.md +1 -1
  52. package/src/skills/typeclaw-git/SKILL.md +1 -1
  53. package/typeclaw.schema.json +10 -0
@@ -18,6 +18,7 @@ import type { ChannelRouter } from '@/channels/router'
18
18
  import type { ChannelAdapterConfig } from '@/channels/schema'
19
19
  import type {
20
20
  ChannelHistoryMessage,
21
+ ChannelSelfIdentityResolver,
21
22
  FetchAttachmentCallback,
22
23
  FetchHistoryArgs,
23
24
  FetchHistoryResult,
@@ -42,6 +43,7 @@ import {
42
43
  type SlackInboundMessageEvent,
43
44
  } from './slack-bot-classify'
44
45
  import { createSlackDedupe } from './slack-bot-dedupe'
46
+ import { enrichSlackReferenceContext } from './slack-bot-reference'
45
47
  import {
46
48
  buildSlashAckPayload,
47
49
  commandResultReply,
@@ -260,6 +262,7 @@ export type SlackBotAdapterOptions = {
260
262
  // classifier behaves as before (no alias-driven thread anchoring), so
261
263
  // tests and ad-hoc adapter constructions stay backwards-compatible.
262
264
  selfAliasesRef?: () => readonly string[]
265
+ fetchImpl?: typeof fetch
263
266
  }
264
267
 
265
268
  export type SlackBotAdapter = {
@@ -350,18 +353,23 @@ export function createTypingCallback(deps: {
350
353
  const { typingTracker, logger, formatChannelTag } = deps
351
354
  return async (target: TypingTarget): Promise<void> => {
352
355
  if (target.adapter !== 'slack-bot') return
356
+ // DMs are flat (thread null) but setStatus still needs a real message ts;
357
+ // `typingThread` carries the inbound ts for exactly that case. Real channel
358
+ // threads keep using `thread`. Either way the status is keyed on one ts.
359
+ const statusThread =
360
+ target.typingThread !== undefined && target.typingThread !== '' ? target.typingThread : target.thread
353
361
  const tag = formatChannelTag
354
- ? await formatChannelTag(target.workspace, target.thread ?? target.chat)
355
- : `channel=${target.thread ?? target.chat}`
356
- if (target.thread === undefined || target.thread === null || target.thread === '') {
362
+ ? await formatChannelTag(target.workspace, statusThread ?? target.chat)
363
+ : `channel=${statusThread ?? target.chat}`
364
+ if (statusThread === undefined || statusThread === null || statusThread === '') {
357
365
  if (target.phase === 'tick') logger.info(`[slack-bot] typing (no-op, top-level chat) ${tag}`)
358
366
  return
359
367
  }
360
368
  if (target.phase === 'stop') {
361
- await typingTracker.clearAfterSend(target.chat, target.thread)
369
+ await typingTracker.clearAfterSend(target.chat, statusThread)
362
370
  return
363
371
  }
364
- await typingTracker.setStatus(target.chat, target.thread, 'is typing...')
372
+ await typingTracker.setStatus(target.chat, statusThread, 'is typing...')
365
373
  }
366
374
  }
367
375
 
@@ -822,7 +830,7 @@ export function createOutboundCallback(deps: {
822
830
  // top-level posts.
823
831
  if (threadTs === null && chunks.length > 1) threadTs = sent.ts
824
832
  }
825
- if (typingTracker) await typingTracker.clearAfterSend(msg.chat, msg.thread)
833
+ if (typingTracker) await typingTracker.clearAfterSend(msg.chat, msg.typingThread ?? msg.thread)
826
834
  return { ok: true }
827
835
  } catch (err) {
828
836
  const message = err instanceof Error ? err.message : String(err)
@@ -855,7 +863,7 @@ export function createOutboundCallback(deps: {
855
863
  return { ok: false, error: `uploadFile failed: ${message}` }
856
864
  }
857
865
  }
858
- if (typingTracker) await typingTracker.clearAfterSend(msg.chat, msg.thread)
866
+ if (typingTracker) await typingTracker.clearAfterSend(msg.chat, msg.typingThread ?? msg.thread)
859
867
  return { ok: true }
860
868
  }
861
869
  }
@@ -896,9 +904,46 @@ export function createFetchAttachmentCallback(deps: {
896
904
  }
897
905
  }
898
906
 
907
+ function createSlackReferenceFetch(deps: { token: string; fetchImpl: typeof fetch }) {
908
+ return async (channelId: string, messageTs: string) => {
909
+ const url = new URL('https://slack.com/api/conversations.replies')
910
+ url.searchParams.set('channel', channelId)
911
+ url.searchParams.set('ts', messageTs)
912
+ url.searchParams.set('limit', '1')
913
+ const response = await deps.fetchImpl(url, { headers: { Authorization: `Bearer ${deps.token}` } })
914
+ if (!response.ok) return null
915
+ const body = recordValue(await response.json())
916
+ if (body === null || body.ok !== true) return null
917
+ const messages = arrayField(body, 'messages')
918
+ const first = recordValue(messages[0])
919
+ if (first === null) return null
920
+ const authorId = stringField(first, 'user') ?? stringField(first, 'bot_id')
921
+ const text = stringField(first, 'text')
922
+ if (authorId === null || text === null) return null
923
+ return { authorId, authorName: authorId, text }
924
+ }
925
+ }
926
+
927
+ function recordValue(value: unknown): Record<string, unknown> | null {
928
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
929
+ ? (value as Record<string, unknown>)
930
+ : null
931
+ }
932
+
933
+ function arrayField(record: Record<string, unknown>, key: string): readonly unknown[] {
934
+ const value = record[key]
935
+ return Array.isArray(value) ? value : []
936
+ }
937
+
938
+ function stringField(record: Record<string, unknown>, key: string): string | null {
939
+ const value = record[key]
940
+ return typeof value === 'string' && value.length > 0 ? value : null
941
+ }
942
+
899
943
  export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBotAdapter {
900
944
  const logger = options.logger ?? consoleLogger
901
945
  const client = new SlackBotClient()
946
+ const fetchImpl = options.fetchImpl ?? fetch
902
947
  let listener: SlackBotListener | null = null
903
948
  let botUserId: string | null = null
904
949
  let teamId: string | null = null
@@ -909,6 +954,11 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
909
954
  const authorResolver = createSlackAuthorResolver({ token: options.token })
910
955
  const channelResolver = createSlackChannelResolver({ token: options.token })
911
956
 
957
+ // Slack mentions by id (`<@U…>`), so no username form. Read live off the
958
+ // closure so a reconnect re-running auth.test stays reflected; one team
959
+ // per token in practice, so `workspace` is ignored.
960
+ const selfIdentityResolver: ChannelSelfIdentityResolver = () => (botUserId !== null ? { id: botUserId } : null)
961
+
912
962
  const formatChannelTag = async (workspace: string, chat: string): Promise<string> => {
913
963
  const names = await channelResolver({ adapter: 'slack-bot', workspace, chat, thread: null }).catch(
914
964
  () => ({}) as ResolvedChannelNames,
@@ -1047,7 +1097,22 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1047
1097
  }
1048
1098
 
1049
1099
  dedupe.mark(event)
1050
- const enriched = { ...verdict.payload, authorName: resolvedUserName }
1100
+ const slackAttachments = Array.isArray(event.attachments) ? event.attachments : undefined
1101
+ const referenceResult = await enrichSlackReferenceContext({
1102
+ text: verdict.payload.text,
1103
+ channelId: event.channel,
1104
+ ...(event.thread_ts !== undefined ? { threadTs: event.thread_ts } : {}),
1105
+ messageTs: event.ts,
1106
+ ...(slackAttachments !== undefined ? { attachments: slackAttachments } : {}),
1107
+ fetchMessage: createSlackReferenceFetch({ token: options.token, fetchImpl }),
1108
+ })
1109
+ const enriched = {
1110
+ ...verdict.payload,
1111
+ authorName: resolvedUserName,
1112
+ ...(referenceResult.referenceContext !== undefined
1113
+ ? { referenceContext: referenceResult.referenceContext }
1114
+ : {}),
1115
+ }
1051
1116
  const routedTag = await formatChannelTag(enriched.workspace, enriched.chat)
1052
1117
  logger.info(
1053
1118
  `[slack-bot] routed ts=${event.ts} ${routedTag} mention=${enriched.isBotMention} reply=${enriched.replyToBotMessageId !== null}`,
@@ -1143,6 +1208,7 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1143
1208
  options.router.registerOutbound('slack-bot', outboundCallback)
1144
1209
  options.router.registerTyping('slack-bot', typingCallback)
1145
1210
  options.router.registerChannelNameResolver('slack-bot', channelResolver)
1211
+ options.router.registerSelfIdentity('slack-bot', selfIdentityResolver)
1146
1212
  options.router.registerHistory('slack-bot', historyCallback)
1147
1213
  options.router.registerFetchAttachment('slack-bot', fetchAttachmentCallback)
1148
1214
  options.router.registerMembership('slack-bot', membershipResolver)
@@ -1162,6 +1228,7 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
1162
1228
  options.router.unregisterOutbound('slack-bot', outboundCallback)
1163
1229
  options.router.unregisterTyping('slack-bot', typingCallback)
1164
1230
  options.router.unregisterChannelNameResolver('slack-bot', channelResolver)
1231
+ options.router.unregisterSelfIdentity('slack-bot', selfIdentityResolver)
1165
1232
  options.router.unregisterHistory('slack-bot', historyCallback)
1166
1233
  options.router.unregisterFetchAttachment('slack-bot', fetchAttachmentCallback)
1167
1234
  options.router.unregisterMembership('slack-bot', membershipResolver)
@@ -6,6 +6,7 @@ import type { ChannelRouter } from '@/channels/router'
6
6
  import type { ChannelAdapterConfig } from '@/channels/schema'
7
7
  import type {
8
8
  ChannelNameResolver,
9
+ ChannelSelfIdentityResolver,
9
10
  FetchAttachmentCallback,
10
11
  OutboundCallback,
11
12
  OutboundMessage,
@@ -384,6 +385,13 @@ export function createTelegramBotAdapter(options: TelegramBotAdapterOptions): Te
384
385
 
385
386
  const channelResolver = createChannelNameResolver({ client })
386
387
 
388
+ // Telegram addresses by `@username`, not by the numeric id, so surface
389
+ // `username` when the bot has one; the id is kept for completeness.
390
+ const selfIdentityResolver: ChannelSelfIdentityResolver = () =>
391
+ botUser !== null
392
+ ? { id: String(botUser.id), ...(botUser.username !== undefined ? { username: botUser.username } : {}) }
393
+ : null
394
+
387
395
  const formatChannelTag = async (chat: string): Promise<string> => {
388
396
  const names = await channelResolver({
389
397
  adapter: 'telegram-bot',
@@ -522,6 +530,7 @@ export function createTelegramBotAdapter(options: TelegramBotAdapterOptions): Te
522
530
  options.router.registerOutbound('telegram-bot', outboundCallback)
523
531
  options.router.registerTyping('telegram-bot', typingCallback)
524
532
  options.router.registerChannelNameResolver('telegram-bot', channelResolver)
533
+ options.router.registerSelfIdentity('telegram-bot', selfIdentityResolver)
525
534
  options.router.registerFetchAttachment('telegram-bot', fetchAttachmentCallback)
526
535
  options.router.registerMembership('telegram-bot', membershipResolver)
527
536
 
@@ -529,6 +538,7 @@ export function createTelegramBotAdapter(options: TelegramBotAdapterOptions): Te
529
538
  options.router.unregisterOutbound('telegram-bot', outboundCallback)
530
539
  options.router.unregisterTyping('telegram-bot', typingCallback)
531
540
  options.router.unregisterChannelNameResolver('telegram-bot', channelResolver)
541
+ options.router.unregisterSelfIdentity('telegram-bot', selfIdentityResolver)
532
542
  options.router.unregisterFetchAttachment('telegram-bot', fetchAttachmentCallback)
533
543
  options.router.unregisterMembership('telegram-bot', membershipResolver)
534
544
  listener?.stop()
@@ -556,6 +566,7 @@ export function createTelegramBotAdapter(options: TelegramBotAdapterOptions): Te
556
566
  options.router.unregisterOutbound('telegram-bot', outboundCallback)
557
567
  options.router.unregisterTyping('telegram-bot', typingCallback)
558
568
  options.router.unregisterChannelNameResolver('telegram-bot', channelResolver)
569
+ options.router.unregisterSelfIdentity('telegram-bot', selfIdentityResolver)
559
570
  options.router.unregisterFetchAttachment('telegram-bot', fetchAttachmentCallback)
560
571
  options.router.unregisterMembership('telegram-bot', membershipResolver)
561
572
  // Stop the listener BEFORE waiting for inflight handlers. The SDK's
@@ -13,7 +13,13 @@ import { createKakaotalkAdapter, type KakaotalkAdapter } from './adapters/kakaot
13
13
  import { createSlackBotAdapter, type SlackBotAdapter } from './adapters/slack-bot'
14
14
  import { createTelegramBotAdapter, type TelegramBotAdapter } from './adapters/telegram-bot'
15
15
  import type { GithubTokenBridge } from './github-token-bridge'
16
- import { createChannelRouter, type ChannelRouter, type ClaimHandler, type CreateSessionForChannel } from './router'
16
+ import {
17
+ createChannelRouter,
18
+ type ChannelRouter,
19
+ type ClaimHandler,
20
+ type CreateSessionForChannel,
21
+ type RestartCommandContext,
22
+ } from './router'
17
23
  import {
18
24
  ADAPTER_IDS,
19
25
  type AdapterId,
@@ -94,7 +100,7 @@ export type ChannelManagerOptions = {
94
100
  // container-restart bindings; tests omit them so the commands stay
95
101
  // unregistered. See CreateChannelRouterOptions.onReload/onRestart.
96
102
  onReload?: () => Promise<string>
97
- onRestart?: () => Promise<string>
103
+ onRestart?: (ctx?: RestartCommandContext) => Promise<string>
98
104
  }
99
105
 
100
106
  export type ChannelManager = {