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.
- package/package.json +1 -1
- package/scripts/generate-schema.ts +4 -6
- package/src/agent/index.ts +26 -4
- package/src/agent/multimodal/look-at.ts +1 -2
- package/src/agent/session-origin.ts +9 -1
- package/src/agent/tools/channel-fetch-attachment.ts +1 -2
- package/src/agent/tools/channel-react.ts +9 -3
- package/src/agent/tools/channel-reply.ts +30 -1
- package/src/agent/tools/channel-send.ts +94 -1
- package/src/bundled-plugins/github-cli-auth/gh-review-detect.ts +175 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +4 -0
- package/src/bundled-plugins/github-cli-auth/review-recorder.ts +93 -0
- package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
- package/src/bundled-plugins/memory/README.md +3 -21
- package/src/bundled-plugins/memory/index.ts +1 -149
- package/src/bundled-plugins/reviewer/skills/code-review.ts +3 -1
- package/src/bundled-plugins/security/policies/cron-promotion.ts +2 -2
- package/src/channels/adapters/github/inbound.ts +155 -9
- package/src/channels/adapters/github/review-thread-resolver.ts +93 -8
- package/src/channels/github-false-receipt.ts +87 -0
- package/src/channels/github-review-claim.ts +91 -0
- package/src/channels/github-review-turn-ledger.ts +71 -0
- package/src/channels/persistence.ts +4 -102
- package/src/channels/router.ts +191 -7
- package/src/channels/schema.ts +20 -5
- package/src/cli/channel.ts +2 -1
- package/src/cli/init.ts +2 -1
- package/src/cli/inspect.ts +216 -36
- package/src/cli/logs.ts +15 -0
- package/src/cli/tui.ts +33 -39
- package/src/compose/logs.ts +1 -1
- package/src/config/config.ts +19 -288
- package/src/container/logs.ts +70 -22
- package/src/container/start.ts +0 -2
- package/src/cron/index.ts +3 -44
- package/src/cron/schema.ts +2 -96
- package/src/init/gitignore.ts +1 -2
- package/src/inspect/index.ts +128 -42
- package/src/inspect/item-list.ts +44 -0
- package/src/inspect/item.ts +17 -0
- package/src/inspect/label.ts +1 -1
- package/src/inspect/logs-item.ts +79 -0
- package/src/inspect/loop.ts +74 -3
- package/src/inspect/open-item.ts +100 -0
- package/src/inspect/preview.ts +106 -0
- package/src/inspect/session-list.ts +15 -3
- package/src/inspect/transcript-view.ts +182 -0
- package/src/inspect/tui-item.ts +97 -0
- package/src/secrets/defaults.ts +1 -18
- package/src/secrets/index.ts +0 -2
- package/src/secrets/schema.ts +4 -90
- package/src/secrets/storage.ts +0 -2
- package/src/server/index.ts +0 -4
- package/src/skills/typeclaw-channel-github/SKILL.md +3 -1
- package/src/skills/typeclaw-config/SKILL.md +9 -11
- package/src/skills/typeclaw-permissions/SKILL.md +1 -1
- package/src/tui/index.ts +72 -32
- package/typeclaw.schema.json +1 -0
- package/src/agent/tools/normalize-ref.ts +0 -11
- package/src/bundled-plugins/memory/migration.ts +0 -633
- package/src/secrets/migrate-kakaotalk.ts +0 -82
- 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 {
|
|
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: '
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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}`)
|
package/src/compose/logs.ts
CHANGED
|
@@ -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
|
|
package/src/config/config.ts
CHANGED
|
@@ -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`).
|
|
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
|
-
//
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
//
|
|
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
|
-
//
|
|
804
|
-
//
|
|
805
|
-
//
|
|
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
|
|
1160
|
-
//
|
|
1161
|
-
//
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
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`.
|
|
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)
|
package/src/container/logs.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/container/start.ts
CHANGED
|
@@ -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
|