typeclaw 0.26.0 → 0.28.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 +1 -1
  2. package/scripts/generate-schema.ts +4 -6
  3. package/src/agent/index.ts +26 -4
  4. package/src/agent/multimodal/look-at.ts +1 -2
  5. package/src/agent/session-origin.ts +9 -1
  6. package/src/agent/tools/channel-fetch-attachment.ts +1 -2
  7. package/src/agent/tools/channel-react.ts +9 -3
  8. package/src/agent/tools/channel-reply.ts +30 -1
  9. package/src/agent/tools/channel-send.ts +94 -1
  10. package/src/bundled-plugins/github-cli-auth/gh-review-detect.ts +175 -0
  11. package/src/bundled-plugins/github-cli-auth/index.ts +4 -0
  12. package/src/bundled-plugins/github-cli-auth/review-recorder.ts +93 -0
  13. package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
  14. package/src/bundled-plugins/memory/README.md +3 -21
  15. package/src/bundled-plugins/memory/index.ts +1 -149
  16. package/src/bundled-plugins/reviewer/skills/code-review.ts +3 -1
  17. package/src/bundled-plugins/security/policies/cron-promotion.ts +2 -2
  18. package/src/channels/adapters/github/inbound.ts +155 -9
  19. package/src/channels/adapters/github/review-thread-resolver.ts +93 -8
  20. package/src/channels/github-false-receipt.ts +87 -0
  21. package/src/channels/github-review-claim.ts +91 -0
  22. package/src/channels/github-review-turn-ledger.ts +71 -0
  23. package/src/channels/persistence.ts +4 -102
  24. package/src/channels/router.ts +191 -7
  25. package/src/channels/schema.ts +20 -5
  26. package/src/cli/channel.ts +2 -1
  27. package/src/cli/init.ts +2 -1
  28. package/src/cli/inspect.ts +216 -36
  29. package/src/cli/logs.ts +15 -0
  30. package/src/cli/tui.ts +33 -39
  31. package/src/compose/logs.ts +1 -1
  32. package/src/config/config.ts +19 -288
  33. package/src/container/logs.ts +70 -22
  34. package/src/container/start.ts +0 -2
  35. package/src/cron/index.ts +3 -44
  36. package/src/cron/schema.ts +2 -96
  37. package/src/init/gitignore.ts +1 -2
  38. package/src/inspect/index.ts +128 -42
  39. package/src/inspect/item-list.ts +44 -0
  40. package/src/inspect/item.ts +17 -0
  41. package/src/inspect/label.ts +1 -1
  42. package/src/inspect/logs-item.ts +79 -0
  43. package/src/inspect/loop.ts +74 -3
  44. package/src/inspect/open-item.ts +100 -0
  45. package/src/inspect/preview.ts +106 -0
  46. package/src/inspect/session-list.ts +15 -3
  47. package/src/inspect/transcript-view.ts +182 -0
  48. package/src/inspect/tui-item.ts +97 -0
  49. package/src/secrets/defaults.ts +1 -18
  50. package/src/secrets/index.ts +0 -2
  51. package/src/secrets/schema.ts +4 -90
  52. package/src/secrets/storage.ts +0 -2
  53. package/src/server/index.ts +0 -4
  54. package/src/skills/typeclaw-channel-github/SKILL.md +3 -1
  55. package/src/skills/typeclaw-config/SKILL.md +9 -11
  56. package/src/skills/typeclaw-permissions/SKILL.md +1 -1
  57. package/src/tui/index.ts +72 -32
  58. package/typeclaw.schema.json +1 -0
  59. package/src/agent/tools/normalize-ref.ts +0 -11
  60. package/src/bundled-plugins/memory/migration.ts +0 -633
  61. package/src/secrets/migrate-kakaotalk.ts +0 -82
  62. package/src/secrets/migrate.ts +0 -96
package/src/cli/tui.ts CHANGED
@@ -3,14 +3,16 @@ import { defineCommand } from 'citty'
3
3
  import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
4
4
  import { findAgentDir } from '@/init'
5
5
  import { CLI_VERSION } from '@/init/cli-version'
6
- import { createTui, formatVersionMismatchWarning } from '@/tui'
6
+ import { runTuiViewer } from '@/inspect'
7
+ import { formatVersionMismatchWarning } from '@/tui'
7
8
 
9
+ import { runInspectViewer } from './inspect'
8
10
  import { errorLine } from './ui'
9
11
 
10
12
  export const tui = defineCommand({
11
13
  meta: {
12
14
  name: 'tui',
13
- description: 'start the tui client',
15
+ description: 'open the live agent session in the read+write viewer (host stage)',
14
16
  },
15
17
  args: {
16
18
  prompt: {
@@ -25,50 +27,42 @@ export const tui = defineCommand({
25
27
  },
26
28
  },
27
29
  async run({ args }) {
28
- const resolveUrl: () => Promise<string> = args.url !== undefined ? async () => args.url as string : defaultUrl
30
+ const cwd = findAgentDir(process.cwd()) ?? process.cwd()
31
+ const resolveUrl: () => Promise<string> =
32
+ args.url !== undefined ? async () => args.url as string : () => defaultUrl(cwd)
29
33
 
30
- let initialPrompt: string | undefined = args.prompt
31
- let attempt = 0
32
- const RECONNECT_MAX_ATTEMPTS = 30
33
- const RECONNECT_BACKOFF_MS = 1_000
34
+ const result = await runTuiViewer({
35
+ resolveUrl,
36
+ ...(args.prompt !== undefined ? { initialPrompt: args.prompt } : {}),
37
+ expectedVersion: CLI_VERSION,
38
+ onVersionMismatch: (info) => {
39
+ process.stderr.write(`${formatVersionMismatchWarning(info)}\n`)
40
+ },
41
+ stderr: (line) => process.stderr.write(`${line}\n`),
42
+ })
34
43
 
35
- while (true) {
36
- const url = await resolveUrl()
37
- const tui = createTui({
38
- url,
39
- ...(initialPrompt !== undefined ? { initialPrompt } : {}),
40
- expectedVersion: CLI_VERSION,
41
- onVersionMismatch: (info) => {
42
- process.stderr.write(`${formatVersionMismatchWarning(info)}\n`)
43
- },
44
- })
45
- const outcome = await tui.run()
46
- if (!outcome.lostConnection) return
47
- // The TUI lost its WS post-handshake (container restart, network blip,
48
- // hostd hiccup). Re-resolve the URL because the host port can change
49
- // across container lifecycles (see resolveHostPort), then reconnect.
50
- // The initial prompt is intentionally cleared after the first cycle:
51
- // on a reconnect, the agent is resuming the same session — replaying
52
- // the prompt would re-send it to the LLM.
53
- initialPrompt = undefined
54
- attempt += 1
55
- if (attempt > RECONNECT_MAX_ATTEMPTS) {
56
- console.error(errorLine(`disconnected; gave up after ${RECONNECT_MAX_ATTEMPTS} reconnect attempts`))
57
- process.exit(1)
58
- }
59
- process.stderr.write(`reconnecting (attempt ${attempt}/${RECONNECT_MAX_ATTEMPTS})...\n`)
60
- await new Promise((resolve) => setTimeout(resolve, RECONNECT_BACKOFF_MS))
44
+ // Esc detached from the live session: drop into the viewer list so the user
45
+ // can pick another session or the container logs — `tui` is just a deep-link
46
+ // into the session viewer, pre-opened on the live session. allowWritable
47
+ // is false because detaching ended the live session, so no row may be
48
+ // offered as a writable "live TUI" anymore.
49
+ if (result.ok && result.escToPicker === true) {
50
+ const viewerExit = await runInspectViewer({ cwd, allowWritable: false })
51
+ process.exit(viewerExit)
52
+ return
61
53
  }
54
+
55
+ if (!result.ok) {
56
+ process.stderr.write(`${errorLine(result.reason)}\n`)
57
+ process.exit(result.exitCode)
58
+ }
59
+ process.exit(result.exitCode)
62
60
  },
63
61
  })
64
62
 
65
- async function defaultUrl(): Promise<string> {
66
- const cwd = findAgentDir(process.cwd()) ?? process.cwd()
63
+ async function defaultUrl(cwd: string): Promise<string> {
67
64
  const precheck = await requireContainerRunning({ cwd })
68
- if (!precheck.ok) {
69
- console.error(errorLine(precheck.reason))
70
- process.exit(1)
71
- }
65
+ if (!precheck.ok) throw new Error(precheck.reason)
72
66
  const port = await resolveHostPort({ cwd })
73
67
  const token = await resolveTuiToken({ cwd })
74
68
  const url = new URL(`ws://127.0.0.1:${port}`)
@@ -100,7 +100,7 @@ export async function composeLogs({
100
100
  follow,
101
101
  ...(tail !== undefined ? { tail } : {}),
102
102
  })
103
- const proc = bun.spawn({ cmd, stdout: 'pipe', stderr: 'pipe' })
103
+ const proc = bun.spawn({ cmd, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
104
104
  return { agent, proc }
105
105
  })
106
106
 
@@ -249,8 +249,7 @@ export const gitignoreSchema = gitignoreObjectSchema.default(() => gitignoreObje
249
249
  export type GitignoreConfig = z.infer<typeof gitignoreSchema>
250
250
 
251
251
  // Same rationale as `dockerSchema`: a `git` namespace today carries `git.ignore`
252
- // and leaves room for future siblings (e.g. `git.attributes`). The one-time
253
- // migration also handles the rename of legacy top-level `gitignore`.
252
+ // and leaves room for future siblings (e.g. `git.attributes`).
254
253
  export const gitSchema = z
255
254
  .object({
256
255
  ignore: gitignoreSchema,
@@ -783,34 +782,17 @@ export function loadConfigSync(cwd: string): Config {
783
782
  return result.data
784
783
  }
785
784
 
786
- // One-shot rename of legacy top-level `dockerfile` / `gitignore` keys into the
787
- // nested `docker.file` / `git.ignore` shape introduced for namespace
788
- // extensibility (`docker.compose`, `git.attributes`, etc. land here later
789
- // without a second migration). Called from every entry point that reads
790
- // `typeclaw.json` so the rest of the pipeline only ever sees the new shape.
791
- //
792
- // Precedence when both legacy and new keys coexist: the new shape wins and
793
- // the legacy key is dropped silently. Two ways this happens in practice:
794
- // 1. User hand-edited the new shape after auto-migration but forgot to
795
- // delete the legacy key.
796
- // 2. Two `typeclaw start` invocations raced on a stale checkout.
797
- // Either way, the new shape is the source of truth — losing the legacy
798
- // duplicate is the right call because it would otherwise be shadowed at
799
- // parse time anyway (`configSchema` has no `dockerfile`/`gitignore` keys).
785
+ // Strips a `channels.github.eventAllowlist` that deep-equals a value `channel
786
+ // add` / `init` previously seeded verbatim, so the config re-tracks the shipped
787
+ // default. Called from every entry point that reads `typeclaw.json` so the rest
788
+ // of the pipeline only ever sees the canonical shape.
800
789
  //
801
790
  // The returned `applied` array names each migration step that fired, so
802
- // callers in `typeclaw start` can build a meaningful git commit message
803
- // instead of a generic "migrate legacy shape" subject. `changed` is the
804
- // boolean equivalent of `applied.length > 0` and is preserved for back-compat
805
- // with the many call sites that only care whether ANY rewrite happened.
806
- export type MigrationStep =
807
- | { kind: 'dockerfile-to-docker-file' }
808
- | { kind: 'gitignore-to-git-ignore' }
809
- | { kind: 'channels-allow-to-roles-member-match'; rules: string[]; dropped: string[] }
810
- | { kind: 'strip-permissions-gate-channel-respond' }
811
- | { kind: 'model-to-models'; ref: string }
812
- | { kind: 'drop-stale-model'; ref: string }
813
- | { kind: 'drop-github-seeded-event-allowlist' }
791
+ // callers in `typeclaw start` can build a meaningful git commit message.
792
+ // `changed` is the boolean equivalent of `applied.length > 0` and is preserved
793
+ // for back-compat with the many call sites that only care whether ANY rewrite
794
+ // happened.
795
+ export type MigrationStep = { kind: 'drop-github-seeded-event-allowlist' }
814
796
 
815
797
  export type MigrationResult = { json: unknown; changed: boolean; applied: MigrationStep[] }
816
798
 
@@ -820,86 +802,13 @@ export function migrateLegacyConfigShape(json: unknown): MigrationResult {
820
802
  }
821
803
 
822
804
  const obj = json as Record<string, unknown>
823
- const hasLegacyDockerfile = 'dockerfile' in obj
824
- const hasLegacyGitignore = 'gitignore' in obj
825
- const channelsAllowMigration = collectChannelsAllowMigration(obj)
826
- const hasLegacyGateChannelRespond = isPlainObject(obj.permissions) && 'gateChannelRespond' in obj.permissions
827
- // The pre-multi-model schema had a top-level `model: KnownModelRef` and no
828
- // `models` key. Detecting the legacy shape requires both: `model` present
829
- // AND `models` absent. If both coexist (user hand-edited after auto-migrate
830
- // but didn't delete the legacy key), `models` wins and `model` is dropped
831
- // silently — same precedence rule as the dockerfile/gitignore migrations.
832
- const hasLegacyModel = 'model' in obj && !('models' in obj) && typeof obj.model === 'string'
833
- const hasStaleModelAlongsideModels = 'model' in obj && 'models' in obj
834
805
  const hasSeededGithubEventAllowlist = isSeededGithubEventAllowlist(obj)
835
- if (
836
- !hasLegacyDockerfile &&
837
- !hasLegacyGitignore &&
838
- !channelsAllowMigration.found &&
839
- !hasLegacyGateChannelRespond &&
840
- !hasLegacyModel &&
841
- !hasStaleModelAlongsideModels &&
842
- !hasSeededGithubEventAllowlist
843
- ) {
806
+ if (!hasSeededGithubEventAllowlist) {
844
807
  return { json, changed: false, applied: [] }
845
808
  }
846
809
 
847
810
  const applied: MigrationStep[] = []
848
811
  const next: Record<string, unknown> = { ...obj }
849
- if (hasLegacyDockerfile) {
850
- const legacy = next.dockerfile
851
- delete next.dockerfile
852
- if (!('docker' in next)) {
853
- next.docker = { file: legacy }
854
- } else if (isPlainObject(next.docker) && !('file' in next.docker)) {
855
- next.docker = { ...next.docker, file: legacy }
856
- }
857
- applied.push({ kind: 'dockerfile-to-docker-file' })
858
- }
859
- if (hasLegacyGitignore) {
860
- const legacy = next.gitignore
861
- delete next.gitignore
862
- if (!('git' in next)) {
863
- next.git = { ignore: legacy }
864
- } else if (isPlainObject(next.git) && !('ignore' in next.git)) {
865
- next.git = { ...next.git, ignore: legacy }
866
- }
867
- applied.push({ kind: 'gitignore-to-git-ignore' })
868
- }
869
- if (channelsAllowMigration.found) {
870
- applyChannelsAllowMigration(next, channelsAllowMigration)
871
- applied.push({
872
- kind: 'channels-allow-to-roles-member-match',
873
- rules: channelsAllowMigration.rules,
874
- dropped: channelsAllowMigration.warnings,
875
- })
876
- }
877
- if (hasLegacyGateChannelRespond) {
878
- const perms = { ...(next.permissions as Record<string, unknown>) }
879
- delete perms.gateChannelRespond
880
- if (Object.keys(perms).length === 0) {
881
- delete next.permissions
882
- } else {
883
- next.permissions = perms
884
- }
885
- applied.push({ kind: 'strip-permissions-gate-channel-respond' })
886
- }
887
- if (hasLegacyModel) {
888
- const ref = next.model as string
889
- delete next.model
890
- next.models = { default: ref }
891
- applied.push({ kind: 'model-to-models', ref })
892
- } else if (hasStaleModelAlongsideModels) {
893
- // `models` wins (per the same precedence rule as dockerfile/gitignore), but
894
- // the drop is still a tracked migration step so the disk rewrite gets a
895
- // commit instead of silently dirtying the worktree. Without this, the
896
- // file would be rewritten by persistMigratedConfig and no commit would
897
- // fire (buildConfigMigrationCommitMessage returns null for empty applied
898
- // lists), contradicting the invariant in persistMigratedConfig's comment.
899
- const ref = typeof next.model === 'string' ? next.model : ''
900
- delete next.model
901
- applied.push({ kind: 'drop-stale-model', ref })
902
- }
903
812
  if (hasSeededGithubEventAllowlist) {
904
813
  dropSeededGithubEventAllowlist(next)
905
814
  applied.push({ kind: 'drop-github-seeded-event-allowlist' })
@@ -937,12 +846,6 @@ function arraysEqual(a: readonly unknown[], b: readonly unknown[]): boolean {
937
846
  return true
938
847
  }
939
848
 
940
- // Builds a meaningful one-line git commit subject for a typeclaw.json
941
- // migration. Single-step migrations get a specific subject; multi-step ones
942
- // fall back to a stable summary subject with the count. The body (after the
943
- // blank line) enumerates each step so `git log -p typeclaw.json` is an
944
- // auditable trail of what legacy shapes the agent has graduated from.
945
- //
946
849
  // Returns null when no steps were applied — callers should not commit in
947
850
  // that case. Keeping the null branch here (vs an empty string) makes the
948
851
  // "nothing happened" case impossible to misuse at the call site.
@@ -950,42 +853,13 @@ export function buildConfigMigrationCommitMessage(applied: readonly MigrationSte
950
853
  const first = applied[0]
951
854
  if (first === undefined) return null
952
855
 
953
- const subject =
954
- applied.length === 1
955
- ? `typeclaw.json: ${shortStepLabel(first)}`
956
- : `typeclaw.json: migrate legacy shape (${applied.length} steps)`
957
-
856
+ const subject = `typeclaw.json: ${shortStepLabel(first)}`
958
857
  const bodyLines: string[] = applied.map((step) => `- ${describeStep(step)}`)
959
-
960
- // Surface dropped rules in the commit body so a user inspecting `git log -p`
961
- // sees exactly which legacy entries had to be hand-re-added (the lossy
962
- // `channel:<id>` case). Without this, the silent-drop is invisible after
963
- // the fact.
964
- for (const step of applied) {
965
- if (step.kind === 'channels-allow-to-roles-member-match' && step.dropped.length > 0) {
966
- for (const warning of step.dropped) {
967
- bodyLines.push(` warning: ${warning}`)
968
- }
969
- }
970
- }
971
-
972
858
  return `${subject}\n\n${bodyLines.join('\n')}\n`
973
859
  }
974
860
 
975
861
  function shortStepLabel(step: MigrationStep): string {
976
862
  switch (step.kind) {
977
- case 'dockerfile-to-docker-file':
978
- return 'lift dockerfile → docker.file'
979
- case 'gitignore-to-git-ignore':
980
- return 'lift gitignore → git.ignore'
981
- case 'channels-allow-to-roles-member-match':
982
- return 'lift channels.<adapter>.allow[] → roles.member.match[]'
983
- case 'strip-permissions-gate-channel-respond':
984
- return 'drop permissions.gateChannelRespond'
985
- case 'model-to-models':
986
- return 'lift model → models.default'
987
- case 'drop-stale-model':
988
- return 'drop stale legacy model alongside models'
989
863
  case 'drop-github-seeded-event-allowlist':
990
864
  return 'drop seeded channels.github.eventAllowlist'
991
865
  }
@@ -993,152 +867,11 @@ function shortStepLabel(step: MigrationStep): string {
993
867
 
994
868
  function describeStep(step: MigrationStep): string {
995
869
  switch (step.kind) {
996
- case 'dockerfile-to-docker-file':
997
- return 'lift top-level dockerfile into docker.file'
998
- case 'gitignore-to-git-ignore':
999
- return 'lift top-level gitignore into git.ignore'
1000
- case 'channels-allow-to-roles-member-match': {
1001
- if (step.rules.length === 0) {
1002
- return 'strip channels.<adapter>.allow[] (no translatable rules)'
1003
- }
1004
- return `lift channels.<adapter>.allow[] → roles.member.match[]: ${step.rules.join(', ')}`
1005
- }
1006
- case 'strip-permissions-gate-channel-respond':
1007
- return 'drop permissions.gateChannelRespond (removed key)'
1008
- case 'model-to-models':
1009
- return `lift top-level model into models.default: ${step.ref}`
1010
- case 'drop-stale-model':
1011
- return step.ref !== ''
1012
- ? `drop stale top-level model (${step.ref}) — models block takes precedence`
1013
- : 'drop stale top-level model — models block takes precedence'
1014
870
  case 'drop-github-seeded-event-allowlist':
1015
871
  return 'drop seeded channels.github.eventAllowlist so it re-tracks the shipped default'
1016
872
  }
1017
873
  }
1018
874
 
1019
- // Channels.<adapter>.allow[] → roles.member.match[] migration.
1020
- //
1021
- // Phase 3 removes the per-adapter allow-list and unifies wake-up gating
1022
- // through `roles.member.match[]` + the `channel.respond` permission. This
1023
- // helper translates legacy `allow` entries into canonical match-rule DSL
1024
- // strings and appends them (deduplicated, preserving declaration order)
1025
- // to `roles.member.match[]`. The `allow` field is then stripped from each
1026
- // adapter block; the block survives — only the field is gone.
1027
- //
1028
- // `channel:<id>` rules cannot round-trip (the DSL forbids
1029
- // wildcard-workspace + specific-chat) and are dropped with a warning. All
1030
- // other shapes translate losslessly per the table in match-rule.ts.
1031
- type ChannelsAllowMigration = {
1032
- found: boolean
1033
- rules: string[]
1034
- warnings: string[]
1035
- }
1036
-
1037
- function collectChannelsAllowMigration(obj: Record<string, unknown>): ChannelsAllowMigration {
1038
- const out: ChannelsAllowMigration = { found: false, rules: [], warnings: [] }
1039
- const channels = obj.channels
1040
- if (!isPlainObject(channels)) return out
1041
- for (const [adapter, value] of Object.entries(channels)) {
1042
- if (!isPlainObject(value)) continue
1043
- if (!('allow' in value)) continue
1044
- out.found = true
1045
- const allow = value.allow
1046
- if (!Array.isArray(allow)) continue
1047
- for (const entry of allow) {
1048
- if (typeof entry !== 'string') continue
1049
- const translated = translateLegacyAllowRule(entry)
1050
- if (translated.kind === 'rule') {
1051
- out.rules.push(translated.value)
1052
- } else {
1053
- out.warnings.push(`channels.${adapter}.allow[]: dropped '${entry}' (${translated.reason})`)
1054
- }
1055
- }
1056
- }
1057
- return out
1058
- }
1059
-
1060
- function applyChannelsAllowMigration(next: Record<string, unknown>, migration: ChannelsAllowMigration): void {
1061
- const channels = next.channels
1062
- if (isPlainObject(channels)) {
1063
- const updated: Record<string, unknown> = {}
1064
- for (const [adapter, value] of Object.entries(channels)) {
1065
- if (isPlainObject(value) && 'allow' in value) {
1066
- const { allow: _allow, ...rest } = value
1067
- updated[adapter] = rest
1068
- } else {
1069
- updated[adapter] = value
1070
- }
1071
- }
1072
- next.channels = updated
1073
- }
1074
-
1075
- if (migration.rules.length === 0) {
1076
- for (const warning of migration.warnings) {
1077
- console.warn(`[config] ${warning}`)
1078
- }
1079
- return
1080
- }
1081
-
1082
- const roles = isPlainObject(next.roles) ? { ...next.roles } : {}
1083
- const member = isPlainObject(roles.member) ? { ...roles.member } : {}
1084
- const existingMatch = Array.isArray(member.match)
1085
- ? (member.match as unknown[]).filter((m) => typeof m === 'string')
1086
- : []
1087
- const seen = new Set<string>(existingMatch as string[])
1088
- const merged = [...(existingMatch as string[])]
1089
- for (const rule of migration.rules) {
1090
- if (!seen.has(rule)) {
1091
- seen.add(rule)
1092
- merged.push(rule)
1093
- }
1094
- }
1095
- member.match = merged
1096
- roles.member = member
1097
- next.roles = roles
1098
-
1099
- console.warn(`[config] migrated channels.<adapter>.allow[] -> roles.member.match[]: ${migration.rules.join(', ')}`)
1100
- for (const warning of migration.warnings) {
1101
- console.warn(`[config] ${warning}`)
1102
- }
1103
- }
1104
-
1105
- type TranslatedRule = { kind: 'rule'; value: string } | { kind: 'drop'; reason: string }
1106
-
1107
- function translateLegacyAllowRule(rule: string): TranslatedRule {
1108
- // Already canonical / cross-platform.
1109
- if (rule === '*') return { kind: 'rule', value: '*' }
1110
- if (rule.startsWith('kakao:')) return { kind: 'rule', value: rule }
1111
-
1112
- // Discord: guild → discord, dm → discord:dm.
1113
- if (rule === 'guild:*') return { kind: 'rule', value: 'discord:*' }
1114
- if (rule.startsWith('guild:')) return { kind: 'rule', value: `discord:${rule.slice('guild:'.length)}` }
1115
- if (rule === 'dm:*') return { kind: 'rule', value: 'discord:dm/*' }
1116
- if (rule.startsWith('dm:')) return { kind: 'rule', value: `discord:dm/${rule.slice('dm:'.length)}` }
1117
-
1118
- // Slack: team → slack, im → slack:dm.
1119
- if (rule === 'team:*') return { kind: 'rule', value: 'slack:*' }
1120
- if (rule.startsWith('team:')) return { kind: 'rule', value: `slack:${rule.slice('team:'.length)}` }
1121
- if (rule === 'im:*') return { kind: 'rule', value: 'slack:dm/*' }
1122
- if (rule.startsWith('im:')) return { kind: 'rule', value: `slack:dm/${rule.slice('im:'.length)}` }
1123
-
1124
- // Telegram: tg → telegram.
1125
- if (rule === 'tg:*') return { kind: 'rule', value: 'telegram:*' }
1126
- if (rule.startsWith('tg:')) return { kind: 'rule', value: `telegram:${rule.slice('tg:'.length)}` }
1127
-
1128
- // `channel:<id>` had no workspace; canonical DSL rejects wildcard
1129
- // workspace + specific chat. Drop with a warning so the operator knows
1130
- // to re-add the rule explicitly with a workspace coordinate.
1131
- if (rule.startsWith('channel:')) {
1132
- return {
1133
- kind: 'drop',
1134
- reason:
1135
- 'channel:<id> rules require an explicit workspace under the new DSL; re-add as discord:<guild>/<id> or slack:<team>/<id>',
1136
- }
1137
- }
1138
-
1139
- return { kind: 'drop', reason: `unrecognized legacy allow shape '${rule}'` }
1140
- }
1141
-
1142
875
  function isPlainObject(value: unknown): value is Record<string, unknown> {
1143
876
  return typeof value === 'object' && value !== null && !Array.isArray(value)
1144
877
  }
@@ -1156,18 +889,16 @@ function persistMigratedConfig(cwd: string, json: unknown, applied: readonly Mig
1156
889
  }
1157
890
 
1158
891
  // Pair the disk rewrite with a git commit so the agent folder is never
1159
- // silently dirty after a legacy-shape migration. typeclaw.json is in
1160
- // git's "tracked" category (unlike Dockerfile, which is regenerated on
1161
- // every start and intentionally gitignored), so an uncommitted rewrite
1162
- // gets mixed into unrelated commits the moment any other tool touches
1163
- // the repo. commitSystemFileSync no-ops on non-git folders, missing
1164
- // Bun, and clean files, so canonical-shape reads pay zero cost.
892
+ // silently dirty after a migration. typeclaw.json is in git's "tracked"
893
+ // category (unlike Dockerfile, which is regenerated on every start and
894
+ // intentionally gitignored), so an uncommitted rewrite gets mixed into
895
+ // unrelated commits the moment any other tool touches the repo.
896
+ // commitSystemFileSync no-ops on non-git folders, missing Bun, and clean
897
+ // files, so canonical-shape reads pay zero cost.
1165
898
  //
1166
899
  // Called from every entry point that reads typeclaw.json (host CLI,
1167
900
  // hostd daemon, container runtime) so the commit follows the rewrite
1168
- // wherever it happens — not only from `typeclaw start`. The earlier
1169
- // design that committed only in start() missed the long-running hostd
1170
- // daemon, doctor, tui, reload, and compose paths.
901
+ // wherever it happens — not only from `typeclaw start`.
1171
902
  const message = buildConfigMigrationCommitMessage(applied)
1172
903
  if (message !== null) {
1173
904
  commitSystemFileSync(cwd, CONFIG_FILE, message)
@@ -44,27 +44,48 @@ export async function logs({
44
44
  return { ok: false, reason: `Container ${plan.containerName} not found. Run \`typeclaw start\` first.` }
45
45
  }
46
46
 
47
- const proc = bun.spawn({ cmd: buildDockerLogsCmd(plan), cwd, stdout: 'pipe', stderr: 'pipe' })
47
+ // stdin:'ignore' `docker logs` never reads stdin, and letting the child
48
+ // hold the TTY breaks the viewer's raw-mode keypress listener (esc/q/ctrl-c
49
+ // stop reaching it, freezing the logs view with no way out).
50
+ const proc = bun.spawn({ cmd: buildDockerLogsCmd(plan), cwd, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
48
51
 
52
+ // `docker logs -f` never exits on its own; aborting the signal must kill it
53
+ // so the pumps' stream readers end. Escalate to SIGKILL if SIGTERM is
54
+ // ignored, otherwise Promise.all(pumps) could hang until the pipes close.
55
+ let killTimer: ReturnType<typeof setTimeout> | undefined
49
56
  const onAbort = (): void => {
50
57
  try {
51
58
  proc.kill('SIGTERM')
59
+ killTimer = setTimeout(() => {
60
+ try {
61
+ proc.kill('SIGKILL')
62
+ } catch {
63
+ // already exited
64
+ }
65
+ }, 2_000)
52
66
  } catch {
53
67
  // already exited
54
68
  }
55
69
  }
56
70
  signal?.addEventListener('abort', onAbort, { once: true })
71
+ // The signal may already be aborted before we attached the listener (esc
72
+ // pressed during container existence check); addEventListener would then
73
+ // never fire, leaving docker logs -f running forever.
74
+ if (signal?.aborted === true) onAbort()
57
75
 
58
- const colorOut = useColor ?? supportsColor(out)
59
- const colorErr = useColor ?? supportsColor(err)
60
- await Promise.all([
61
- pumpWithTimestamps(proc.stdout, out, makeLogTimestampReformatter(undefined, { color: colorOut })),
62
- pumpWithTimestamps(proc.stderr, err, makeLogTimestampReformatter(undefined, { color: colorErr })),
63
- ])
64
- const exitCode = await proc.exited
65
- signal?.removeEventListener('abort', onAbort)
66
-
67
- return { ok: true, containerName: plan.containerName, exitCode }
76
+ try {
77
+ const colorOut = useColor ?? supportsColor(out)
78
+ const colorErr = useColor ?? supportsColor(err)
79
+ await Promise.all([
80
+ pumpWithTimestamps(proc.stdout, out, makeLogTimestampReformatter(undefined, { color: colorOut }), signal),
81
+ pumpWithTimestamps(proc.stderr, err, makeLogTimestampReformatter(undefined, { color: colorErr }), signal),
82
+ ])
83
+ const exitCode = await proc.exited
84
+ return { ok: true, containerName: plan.containerName, exitCode }
85
+ } finally {
86
+ if (killTimer !== undefined) clearTimeout(killTimer)
87
+ signal?.removeEventListener('abort', onAbort)
88
+ }
68
89
  } catch (error) {
69
90
  return { ok: false, reason: error instanceof Error ? error.message : String(error) }
70
91
  }
@@ -101,30 +122,57 @@ export function buildDockerLogsCmd(plan: LogsPlan): string[] {
101
122
 
102
123
  // Exported for `compose/logs.ts` so the multi-agent path reuses the same
103
124
  // reformatter and stays consistent with single-agent output.
125
+ //
126
+ // Abort handling is load-bearing for the interactive logs viewer: killing
127
+ // `docker logs -f` does NOT reliably make Bun's pending `reader.read()` resolve
128
+ // (the killed child may not promptly EOF its piped stdout — see the OrbStack
129
+ // /proc quirk). Without cancelling the reader on abort, esc would hang forever.
130
+ // So on abort we cancel the reader, which unblocks the pending read; the caller
131
+ // still kills the process for OS-side cleanup.
104
132
  export async function pumpWithTimestamps(
105
133
  stream: ReadableStream<Uint8Array>,
106
134
  sink: NodeJS.WritableStream,
107
135
  reformatter: TimestampReformatter = makeLogTimestampReformatter(),
136
+ signal?: AbortSignal,
108
137
  ): Promise<void> {
109
138
  const decoder = new TextDecoder()
110
139
  const reader = stream.getReader()
140
+ let aborted = signal?.aborted === true
141
+ const onAbort = (): void => {
142
+ aborted = true
143
+ void reader.cancel().catch(() => {})
144
+ }
145
+ if (aborted) onAbort()
146
+ else signal?.addEventListener('abort', onAbort, { once: true })
147
+
111
148
  try {
112
149
  while (true) {
113
- const { done, value } = await reader.read()
114
- if (done) break
115
- if (value && value.byteLength > 0) {
116
- const out = reformatter.write(decoder.decode(value, { stream: true }))
150
+ if (aborted) break
151
+ const chunk = await reader.read().catch((error: unknown) => {
152
+ if (aborted || signal?.aborted === true) return null
153
+ throw error
154
+ })
155
+ if (chunk === null || chunk.done || aborted) break
156
+ if (chunk.value && chunk.value.byteLength > 0) {
157
+ const out = reformatter.write(decoder.decode(chunk.value, { stream: true }))
117
158
  if (out.length > 0) sink.write(out)
118
159
  }
119
160
  }
120
- const tail = decoder.decode()
121
- if (tail.length > 0) {
122
- const out = reformatter.write(tail)
123
- if (out.length > 0) sink.write(out)
161
+ if (!aborted) {
162
+ const tail = decoder.decode()
163
+ if (tail.length > 0) {
164
+ const out = reformatter.write(tail)
165
+ if (out.length > 0) sink.write(out)
166
+ }
167
+ const flushed = reformatter.flush()
168
+ if (flushed.length > 0) sink.write(flushed)
124
169
  }
125
- const flushed = reformatter.flush()
126
- if (flushed.length > 0) sink.write(flushed)
127
170
  } finally {
128
- reader.releaseLock()
171
+ signal?.removeEventListener('abort', onAbort)
172
+ try {
173
+ reader.releaseLock()
174
+ } catch {
175
+ // harmless if cancel/abort raced with stream teardown
176
+ }
129
177
  }
130
178
  }
@@ -22,7 +22,6 @@ import { ensureDepsInstalled, type EnsureDepsResult } from '@/init/ensure-deps'
22
22
  import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
23
23
  import { refreshPackageJson } from '@/init/packagejson'
24
24
  import { runBunUpdate, type UpdateRunner } from '@/init/run-bun-install'
25
- import { migrateKakaotalkCredentials } from '@/secrets'
26
25
 
27
26
  import { CONTAINER_PORT, TUI_TOKEN_LABEL, findFreePort, isPortAllocatedError, resolveTuiToken } from './port'
28
27
  import {
@@ -258,7 +257,6 @@ export async function start({
258
257
  // ensures the base image's CLI version matches the runtime the
259
258
  // container will actually load.
260
259
  const dockerfileRefresh = await refreshDockerfile(cwd)
261
- await migrateKakaotalkCredentials(cwd)
262
260
 
263
261
  if (state.exists) {
264
262
  // Container holds the name but is not running. Without `--rm`, this is