typeclaw 0.27.0 → 0.28.1

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 (51) 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/provider-error.ts +33 -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 +52 -1
  9. package/src/agent/tools/channel-send.ts +115 -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/security/policies/cron-promotion.ts +2 -2
  17. package/src/channels/adapters/github/inbound.ts +103 -0
  18. package/src/channels/adapters/github/index.ts +10 -0
  19. package/src/channels/adapters/github/review-state.ts +137 -0
  20. package/src/channels/adapters/github/review-thread-resolver.ts +65 -5
  21. package/src/channels/github-false-receipt.ts +87 -0
  22. package/src/channels/github-rereview-guard.ts +76 -0
  23. package/src/channels/github-review-claim.ts +92 -0
  24. package/src/channels/github-review-turn-ledger.ts +71 -0
  25. package/src/channels/persistence.ts +4 -102
  26. package/src/channels/router.ts +181 -7
  27. package/src/channels/schema.ts +20 -5
  28. package/src/channels/types.ts +31 -0
  29. package/src/cli/channel.ts +2 -1
  30. package/src/cli/init.ts +2 -1
  31. package/src/config/config.ts +19 -288
  32. package/src/container/start.ts +0 -2
  33. package/src/cron/index.ts +3 -44
  34. package/src/cron/schema.ts +2 -96
  35. package/src/init/gitignore.ts +1 -2
  36. package/src/inspect/transcript-view.ts +10 -0
  37. package/src/secrets/defaults.ts +1 -18
  38. package/src/secrets/index.ts +0 -2
  39. package/src/secrets/schema.ts +4 -90
  40. package/src/secrets/storage.ts +0 -2
  41. package/src/server/index.ts +11 -5
  42. package/src/shared/protocol.ts +18 -6
  43. package/src/skills/typeclaw-config/SKILL.md +9 -11
  44. package/src/skills/typeclaw-permissions/SKILL.md +1 -1
  45. package/src/tui/format.ts +13 -0
  46. package/src/tui/index.ts +21 -7
  47. package/typeclaw.schema.json +1 -0
  48. package/src/agent/tools/normalize-ref.ts +0 -11
  49. package/src/bundled-plugins/memory/migration.ts +0 -633
  50. package/src/secrets/migrate-kakaotalk.ts +0 -82
  51. package/src/secrets/migrate.ts +0 -96
@@ -128,6 +128,69 @@ function parseDecimalId(value: string | undefined): number | null {
128
128
  }
129
129
 
130
130
  async function findThread(fetchImpl: typeof fetch, token: string, target: ResolveTarget): Promise<ThreadLookup> {
131
+ let lookup: ThreadLookup = { kind: 'absent' }
132
+ const outcome = await walkThreadPages(
133
+ fetchImpl,
134
+ token,
135
+ { owner: target.owner, repo: target.repo, prNumber: target.prNumber },
136
+ (nodes) => {
137
+ for (const node of nodes) {
138
+ if (node.rootCommentId === target.rootCommentId) {
139
+ lookup = { kind: 'found', thread: node }
140
+ return 'stop'
141
+ }
142
+ }
143
+ return 'continue'
144
+ },
145
+ )
146
+ if (outcome.kind === 'error') return { kind: 'error', result: outcome.result }
147
+ return lookup
148
+ }
149
+
150
+ export type UnresolvedSelfReviewThread = { threadId: string; rootCommentId: number }
151
+
152
+ export type ListUnresolvedSelfReviewThreadsResult =
153
+ | { ok: true; threads: UnresolvedSelfReviewThread[] }
154
+ | { ok: false; error: string }
155
+
156
+ // Reuses the same page-walk and authorship guard (`isSelfAuthor`) as the
157
+ // single-thread resolver so the post-push "did my comments get addressed?"
158
+ // sweep can never surface a human reviewer's thread as a resolve candidate.
159
+ export async function listUnresolvedSelfReviewThreads(deps: {
160
+ token: string
161
+ selfLogin: string
162
+ owner: string
163
+ repo: string
164
+ prNumber: number
165
+ fetchImpl?: typeof fetch
166
+ }): Promise<ListUnresolvedSelfReviewThreadsResult> {
167
+ const fetchImpl = deps.fetchImpl ?? fetch
168
+ const threads: UnresolvedSelfReviewThread[] = []
169
+ const outcome = await walkThreadPages(
170
+ fetchImpl,
171
+ deps.token,
172
+ { owner: deps.owner, repo: deps.repo, prNumber: deps.prNumber },
173
+ (nodes) => {
174
+ for (const node of nodes) {
175
+ if (node.isResolved || node.rootCommentId === null) continue
176
+ if (!isSelfAuthor(node, deps.selfLogin)) continue
177
+ threads.push({ threadId: node.id, rootCommentId: node.rootCommentId })
178
+ }
179
+ return 'continue'
180
+ },
181
+ )
182
+ if (outcome.kind === 'error') return { ok: false, error: outcome.result.error }
183
+ return { ok: true, threads }
184
+ }
185
+
186
+ type WalkOutcome = { kind: 'done' } | { kind: 'error'; result: ReviewThreadResolveResult & { ok: false } }
187
+
188
+ async function walkThreadPages(
189
+ fetchImpl: typeof fetch,
190
+ token: string,
191
+ target: { owner: string; repo: string; prNumber: number },
192
+ onPage: (nodes: ReviewThreadNode[]) => 'stop' | 'continue',
193
+ ): Promise<WalkOutcome> {
131
194
  let after: string | null = null
132
195
  for (;;) {
133
196
  let response: Response
@@ -148,11 +211,8 @@ async function findThread(fetchImpl: typeof fetch, token: string, target: Resolv
148
211
  }
149
212
  const parsed = await parseThreadsPage(response)
150
213
  if (parsed.kind === 'error') return { kind: 'error', result: parsed.result }
151
-
152
- for (const node of parsed.nodes) {
153
- if (node.rootCommentId === target.rootCommentId) return { kind: 'found', thread: node }
154
- }
155
- if (!parsed.hasNextPage || parsed.endCursor === null) return { kind: 'absent' }
214
+ if (onPage(parsed.nodes) === 'stop') return { kind: 'done' }
215
+ if (!parsed.hasNextPage || parsed.endCursor === null) return { kind: 'done' }
156
216
  after = parsed.endCursor
157
217
  }
158
218
  }
@@ -0,0 +1,87 @@
1
+ import { classifyReviewClaim } from './github-review-claim'
2
+ import { hasResolvedThread, hasReview } from './github-review-turn-ledger'
3
+
4
+ // Decides whether a github PR reply is a false receipt: prose that CLAIMS a
5
+ // formal verdict / thread close-out the agent never actually performed this turn.
6
+ // Pure except for the ledger reads (module singletons); returns the action the
7
+ // channel_reply tool should take. Block only the black-and-white cases; warn on
8
+ // soft signals so casual chatter is never hard-denied.
9
+
10
+ export type FalseReceiptDecision =
11
+ | { kind: 'allow' }
12
+ | { kind: 'block'; reason: string }
13
+ | { kind: 'warn'; notice: string }
14
+
15
+ export type FalseReceiptInput = {
16
+ sessionId: string
17
+ adapter: string
18
+ workspace: string
19
+ chat: string
20
+ thread: string | null
21
+ text: string | undefined
22
+ isContinue: boolean
23
+ resolveReviewThread: boolean
24
+ }
25
+
26
+ export function checkFalseReceipt(input: FalseReceiptInput): FalseReceiptDecision {
27
+ if (input.adapter !== 'github') return { kind: 'allow' }
28
+ const prNumber = prNumberFromChat(input.chat)
29
+ if (prNumber === null) return { kind: 'allow' }
30
+
31
+ const claim = classifyReviewClaim(input.text ?? '')
32
+ if (claim === 'ignore') return { kind: 'allow' }
33
+ if (claim === 'warn') return { kind: 'warn', notice: SOFT_NOTICE }
34
+
35
+ // A turn the agent explicitly keeps alive (continue:true) is not yet a receipt
36
+ // — the real action may still be coming. Never block; nudge instead.
37
+ if (input.isContinue) return { kind: 'warn', notice: SOFT_NOTICE }
38
+
39
+ if (claim === 'block-resolve') {
40
+ if (input.thread === null) return { kind: 'allow' }
41
+ if (input.resolveReviewThread) return { kind: 'allow' }
42
+ if (
43
+ hasResolvedThread({
44
+ sessionId: input.sessionId,
45
+ workspace: input.workspace,
46
+ prNumber,
47
+ rootCommentId: input.thread,
48
+ })
49
+ ) {
50
+ return { kind: 'allow' }
51
+ }
52
+ return { kind: 'block', reason: RESOLVE_REASON }
53
+ }
54
+
55
+ const verdict = claim === 'block-approve' ? 'APPROVE' : 'REQUEST_CHANGES'
56
+ if (hasReview({ sessionId: input.sessionId, workspace: input.workspace, prNumber, verdict })) {
57
+ return { kind: 'allow' }
58
+ }
59
+ return { kind: 'block', reason: verdict === 'APPROVE' ? APPROVE_REASON : REQUEST_CHANGES_REASON }
60
+ }
61
+
62
+ function prNumberFromChat(chat: string): number | null {
63
+ const m = /^pr:(\d+)$/.exec(chat)
64
+ if (m === null) return null
65
+ const n = Number(m[1])
66
+ return Number.isSafeInteger(n) && n > 0 ? n : null
67
+ }
68
+
69
+ const APPROVE_REASON =
70
+ 'This reply reads as a formal approval, but no APPROVE review was submitted on this PR this turn. ' +
71
+ 'A chat comment is not a GitHub review — submit the formal review via `gh api -X POST /repos/<owner>/<repo>/pulls/<N>/reviews` ' +
72
+ '(event: APPROVE) first, then narrate if needed. If you are not actually approving, reword the reply.'
73
+
74
+ const REQUEST_CHANGES_REASON =
75
+ 'This reply reads as a formal "request changes", but no REQUEST_CHANGES review was submitted on this PR this turn. ' +
76
+ 'Submit the formal review via `gh api -X POST /repos/<owner>/<repo>/pulls/<N>/reviews` (event: REQUEST_CHANGES) first. ' +
77
+ 'If you are not actually requesting changes, reword the reply.'
78
+
79
+ const RESOLVE_REASON =
80
+ 'This reply reads as closing out a review thread, but `resolve_review_thread: true` was not set and the thread ' +
81
+ 'was not resolved this turn. Pass `resolve_review_thread: true` on this reply to actually resolve it, ' +
82
+ 'or reword if the thread should stay open.'
83
+
84
+ const SOFT_NOTICE =
85
+ 'Note: a chat comment does not create a formal GitHub review or resolve a thread. ' +
86
+ 'If you mean to approve / request changes, submit a formal review via `gh api`; ' +
87
+ 'to close a thread you authored, set `resolve_review_thread: true`.'
@@ -0,0 +1,76 @@
1
+ import { classifyReviewClaim } from './github-review-claim'
2
+ import type { ReviewStateResult } from './types'
3
+
4
+ // The re-review stranding guard. A bot that resolves a review thread (or posts a
5
+ // close-out ack) while it still holds its own sticky CHANGES_REQUESTED leaves the
6
+ // PR blocked forever — the resolve/ack carries no review state, so GitHub's
7
+ // reviewDecision never clears (PR #644). This guard blocks that close-out and
8
+ // tells the model to land a formal APPROVE / dismissal first.
9
+ //
10
+ // It is the same enforcement seam as the false-receipt guard and the
11
+ // resolve-thread author check: BLOCK and instruct, never act on the model's
12
+ // behalf — the runtime cannot prove a semantic approval from "one thread closed".
13
+
14
+ export type RereviewGuardInput = {
15
+ adapter: string
16
+ chat: string
17
+ thread: string | null
18
+ text: string | undefined
19
+ wantsResolve: boolean
20
+ getReviewState: (req: { adapter: 'github'; workspace: string; chat: string }) => Promise<ReviewStateResult>
21
+ workspace: string
22
+ }
23
+
24
+ export type RereviewGuardDecision = { block: false } | { block: true; reason: string }
25
+
26
+ const ALLOW: RereviewGuardDecision = { block: false }
27
+
28
+ export async function evaluateRereviewGuard(input: RereviewGuardInput): Promise<RereviewGuardDecision> {
29
+ if (input.adapter !== 'github') return ALLOW
30
+ if (!/^pr:\d+$/.test(input.chat)) return ALLOW
31
+ // No `thread === null` exemption: a top-level PR comment carries no thread but
32
+ // a close-out ack in it ("Verified — that closes it") strands the block just
33
+ // as a thread reply would. Only the resolve ACTION needs a thread; the
34
+ // text-claim path fires regardless (caught by isCloseoutAttempt below).
35
+ if (!isCloseoutAttempt(input.wantsResolve, input.thread, input.text)) return ALLOW
36
+
37
+ const state = await input.getReviewState({ adapter: 'github', workspace: input.workspace, chat: input.chat })
38
+
39
+ // Fail closed: an unverifiable review state is treated as a live block, so the
40
+ // bot never strands a re-review on a transient API failure.
41
+ if (!state.ok) return { block: true, reason: unverifiableReason(state.error) }
42
+ if (!state.selfBlocking) return ALLOW
43
+
44
+ return { block: true, reason: state.approve ? STICKY_BLOCK_APPROVE_ENABLED : STICKY_BLOCK_APPROVE_DISABLED }
45
+ }
46
+
47
+ // Trigger when the model asks to resolve a thread (only meaningful with a
48
+ // thread), OR when its reply reads as a close-out/verdict claim — the latter
49
+ // strands the block whether or not it sits in a thread, so it fires for any PR
50
+ // chat. Plain discussion replies (ignore/warn) do not fire.
51
+ function isCloseoutAttempt(wantsResolve: boolean, thread: string | null, text: string | undefined): boolean {
52
+ if (wantsResolve && thread !== null) return true
53
+ const claim = classifyReviewClaim(text ?? '')
54
+ return claim === 'block-resolve' || claim === 'block-approve'
55
+ }
56
+
57
+ function unverifiableReason(error: string): string {
58
+ return (
59
+ 'Could not verify whether your prior CHANGES_REQUESTED on this PR is still live ' +
60
+ `(${error}). Refusing to close out the thread while the block state is unknown — ` +
61
+ 'retry once the GitHub API is reachable, or land a formal review verdict first.'
62
+ )
63
+ }
64
+
65
+ const STICKY_BLOCK_APPROVE_ENABLED =
66
+ 'You still hold a CHANGES_REQUESTED on this PR. Resolving the thread (or posting a close-out ack) ' +
67
+ 'does NOT clear it — only a fresh formal review does. Submit `APPROVE` via ' +
68
+ '`gh api -X POST /repos/<owner>/<repo>/pulls/<N>/reviews` (event: APPROVE) if the blockers are fixed, ' +
69
+ 'or `REQUEST_CHANGES` if not, THEN resolve the thread / reply.'
70
+
71
+ const STICKY_BLOCK_APPROVE_DISABLED =
72
+ 'You still hold a CHANGES_REQUESTED on this PR and resolving the thread does NOT clear it. ' +
73
+ 'Approval is disabled for this agent (channels.github.review.approve: false), so you cannot APPROVE — ' +
74
+ 'dismiss your prior review via ' +
75
+ '`gh api -X PUT /repos/<owner>/<repo>/pulls/<N>/reviews/<review_id>/dismissals -f message="..." -f event=DISMISS` ' +
76
+ 'if the blockers are fixed (or submit REQUEST_CHANGES if not), THEN resolve the thread / reply.'
@@ -0,0 +1,92 @@
1
+ // Deterministic phrase classifier for the false-receipt guard (channel-reply.ts):
2
+ // how strongly does a github PR reply CLAIM a formal verdict/close-out it may not
3
+ // have actually performed? The taxonomy errs toward WARN over BLOCK on purpose —
4
+ // a false block breaks a legitimate reply; a missed soft-fake only loses a nudge.
5
+
6
+ export type ReviewClaim = 'block-approve' | 'block-request-changes' | 'block-resolve' | 'warn' | 'ignore'
7
+
8
+ // Word-boundary anchored so "approved" never fires inside "unapproved".
9
+ const BLOCK_APPROVE: readonly RegExp[] = [
10
+ /\bapproved\b/,
11
+ /\bapproving\b/,
12
+ /\bi approve\b/,
13
+ /\bapproval (submitted|sent|posted)\b/,
14
+ /\bsubmitting (the )?approval\b/,
15
+ /\bformal approval\b/,
16
+ /\blgtm,? approved\b/,
17
+ ]
18
+
19
+ const BLOCK_REQUEST_CHANGES: readonly RegExp[] = [
20
+ /\brequest(ing|ed)? changes\b/,
21
+ /\bchanges requested\b/,
22
+ /\bi request changes\b/,
23
+ /\bblocking (this|the|merge)\b/,
24
+ /\bthis is blocked\b/,
25
+ ]
26
+
27
+ // Bare "resolved" is intentionally NOT here — it collides with the warn-tier
28
+ // "looks resolved?"; resolve claims must carry a definite marker (marked/that/
29
+ // this/thanks) or a verify clause. "that/this closes it" is the canonical PR
30
+ // #644 incident phrasing and must classify as a close-out claim.
31
+ const BLOCK_RESOLVE: readonly RegExp[] = [
32
+ /\bmarked resolved\b/,
33
+ /\bthread resolved\b/,
34
+ /\bthat resolves it\b/,
35
+ /\bthis resolves it\b/,
36
+ /\b(that|this) closes it\b/,
37
+ /\bclosing this out\b/,
38
+ /\bconfirmed fixed\b/,
39
+ // verify clause + a fix/resolve verb, allowing a short gap ("verified at <sha>, that fixes it").
40
+ /\b(verified|confirmed)\b[^.!?]*\b(fix(es|ed)|resolv)/,
41
+ /\b(thanks,?|fixed,?) (looks )?resolved\b/,
42
+ ]
43
+
44
+ // Casual phrasing that might be chatter, not a formal close-out: allow + nudge.
45
+ const WARN: readonly RegExp[] = [
46
+ /\blgtm\b/,
47
+ /\blooks good\b/,
48
+ /\blooks fine\b/,
49
+ /\bseems fine\b/,
50
+ /\bshould be (fine|good)\b/,
51
+ /\bneeds changes\b/,
52
+ /\bstill needs work\b/,
53
+ /\blooks resolved\b/,
54
+ /\bseems resolved\b/,
55
+ ]
56
+
57
+ // Negation / future-intent / past-reference markers DEMOTE a positive match to
58
+ // ignore. Blocking "I haven't approved" / "I'll approve" / "approved it earlier"
59
+ // (answering a question) is the worst false-positive class, so it is checked first.
60
+ const DEMOTE_TO_IGNORE: readonly RegExp[] = [
61
+ /\b(haven'?t|have not|did ?n'?t|did not|not yet|never)\b[^.!?]*\b(approv|request|resolv|block)/,
62
+ /\b(can'?t|cannot|won'?t|will not|wouldn'?t)\b[^.!?]*\b(approv|request|resolv|block)/,
63
+ /\bnot (approved|resolved|blocked|requesting)\b/,
64
+ /\b(i'?ll|i will|going to|gonna|about to|planning to)\b[^.!?]*\b(approv|review|request|resolv)/,
65
+ /\b(approved|resolved|requested changes)\b[^.!?]*\b(earlier|already|yesterday|before|last (review|time)|previously)\b/,
66
+ ]
67
+
68
+ export function classifyReviewClaim(rawText: string): ReviewClaim {
69
+ const text = normalize(rawText)
70
+ if (text === '') return 'ignore'
71
+
72
+ if (DEMOTE_TO_IGNORE.some((re) => re.test(text))) return 'ignore'
73
+
74
+ // Block-tier wins over warn-tier: an unambiguous "approved" in a casual message
75
+ // is still a formal claim.
76
+ if (BLOCK_APPROVE.some((re) => re.test(text))) return 'block-approve'
77
+ if (BLOCK_REQUEST_CHANGES.some((re) => re.test(text))) return 'block-request-changes'
78
+ if (BLOCK_RESOLVE.some((re) => re.test(text))) return 'block-resolve'
79
+ if (WARN.some((re) => re.test(text))) return 'warn'
80
+ return 'ignore'
81
+ }
82
+
83
+ // Strips markdown/emoji noise so "**Approved!**" and "approved" classify alike,
84
+ // keeping apostrophes + sentence punctuation that the negation regexes rely on.
85
+ function normalize(text: string): string {
86
+ return text
87
+ .toLowerCase()
88
+ .replace(/[*_`~#>]/g, ' ')
89
+ .replace(/[^\p{L}\p{N}\s'.!?]/gu, ' ')
90
+ .replace(/\s+/g, ' ')
91
+ .trim()
92
+ }
@@ -0,0 +1,71 @@
1
+ // In-process record of REAL github review actions performed during the current
2
+ // turn, shared across two plugin boundaries: github-cli-auth records a formal
3
+ // review / thread-resolve here after the `gh` command SUCCEEDS, and channel-reply
4
+ // consults it before sending a verdict/close-out reply. If the agent claims a
5
+ // verdict in prose but this ledger shows no matching action this turn, the reply
6
+ // is a false receipt (see channel-reply.ts). State is per-session and reset at
7
+ // turn start, so a claim must be backed by an action in the SAME turn.
8
+
9
+ export type ReviewVerdict = 'APPROVE' | 'REQUEST_CHANGES'
10
+
11
+ type PrKey = string
12
+ type ThreadKey = string
13
+
14
+ const reviewsByPr = new Map<PrKey, Set<ReviewVerdict>>()
15
+ const resolvedThreads = new Set<ThreadKey>()
16
+
17
+ function prKey(sessionId: string, workspace: string, prNumber: number): PrKey {
18
+ return `${sessionId}::${workspace}::${prNumber}`
19
+ }
20
+
21
+ function threadKey(sessionId: string, workspace: string, prNumber: number, rootCommentId: string): ThreadKey {
22
+ return `${sessionId}::${workspace}::${prNumber}::${rootCommentId}`
23
+ }
24
+
25
+ export function resetReviewTurn(sessionId: string): void {
26
+ for (const key of reviewsByPr.keys()) {
27
+ if (key.startsWith(`${sessionId}::`)) reviewsByPr.delete(key)
28
+ }
29
+ for (const key of resolvedThreads) {
30
+ if (key.startsWith(`${sessionId}::`)) resolvedThreads.delete(key)
31
+ }
32
+ }
33
+
34
+ export function recordReview(args: {
35
+ sessionId: string
36
+ workspace: string
37
+ prNumber: number
38
+ verdict: ReviewVerdict
39
+ }): void {
40
+ const key = prKey(args.sessionId, args.workspace, args.prNumber)
41
+ const set = reviewsByPr.get(key) ?? new Set<ReviewVerdict>()
42
+ set.add(args.verdict)
43
+ reviewsByPr.set(key, set)
44
+ }
45
+
46
+ export function hasReview(args: {
47
+ sessionId: string
48
+ workspace: string
49
+ prNumber: number
50
+ verdict: ReviewVerdict
51
+ }): boolean {
52
+ return reviewsByPr.get(prKey(args.sessionId, args.workspace, args.prNumber))?.has(args.verdict) ?? false
53
+ }
54
+
55
+ export function recordResolvedThread(args: {
56
+ sessionId: string
57
+ workspace: string
58
+ prNumber: number
59
+ rootCommentId: string
60
+ }): void {
61
+ resolvedThreads.add(threadKey(args.sessionId, args.workspace, args.prNumber, args.rootCommentId))
62
+ }
63
+
64
+ export function hasResolvedThread(args: {
65
+ sessionId: string
66
+ workspace: string
67
+ prNumber: number
68
+ rootCommentId: string
69
+ }): boolean {
70
+ return resolvedThreads.has(threadKey(args.sessionId, args.workspace, args.prNumber, args.rootCommentId))
71
+ }
@@ -1,4 +1,4 @@
1
- import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
2
2
  import { dirname, join } from 'node:path'
3
3
 
4
4
  import type { ChannelParticipant } from '@/agent/session-origin'
@@ -16,10 +16,8 @@ const FILE_VERSION = 4
16
16
  // UUID, which never matches on disk — every restart silently creates a
17
17
  // fresh session and the channel loses its transcript memory.
18
18
  //
19
- // `sessionFile` is optional because v2 records (pre-fix) only carried the
20
- // UUID. Those are migrated in-place at load time by globbing the sessions
21
- // directory for `*_${sessionId}.jsonl`; if no match is found the file is
22
- // considered lost and reopen will fall back to a fresh session.
19
+ // `sessionFile` is optional because a session can exist in memory before a
20
+ // transcript path is known; reopen falls back to a fresh session when absent.
23
21
  export type ChannelSessionRecord = {
24
22
  adapter: AdapterId
25
23
  workspace: string
@@ -36,16 +34,6 @@ type FileV4 = {
36
34
  sessions: ChannelSessionRecord[]
37
35
  }
38
36
 
39
- type FileV3 = {
40
- version: 3
41
- sessions: ChannelSessionRecord[]
42
- }
43
-
44
- type FileV2 = {
45
- version: 2
46
- sessions: Array<Omit<ChannelSessionRecord, 'sessionFile'>>
47
- }
48
-
49
37
  export type ChannelSessionsLogger = {
50
38
  info: (msg: string) => void
51
39
  warn: (msg: string) => void
@@ -62,10 +50,6 @@ export function channelsSessionsPath(agentDir: string): string {
62
50
  return join(agentDir, 'channels', 'sessions.json')
63
51
  }
64
52
 
65
- function sessionsDirOf(agentDir: string): string {
66
- return join(agentDir, 'sessions')
67
- }
68
-
69
53
  export async function loadChannelSessions(
70
54
  agentDir: string,
71
55
  logger: ChannelSessionsLogger = consoleLogger,
@@ -94,21 +78,7 @@ export async function loadChannelSessions(
94
78
  if (!Array.isArray(file.sessions)) return []
95
79
  return file.sessions.filter(isValidRecord)
96
80
  }
97
- if (version === 3) {
98
- const file = parsed as FileV3
99
- if (!Array.isArray(file.sessions)) return []
100
- return migrateV3ToV4(file.sessions.filter(isValidRecord), logger)
101
- }
102
- if (version === 2) {
103
- const file = parsed as FileV2
104
- if (!Array.isArray(file.sessions)) return []
105
- const v2Records = file.sessions.filter(isValidV2Record)
106
- const v3Records = await migrateV2Records(agentDir, v2Records, logger)
107
- return migrateV3ToV4(v3Records, logger)
108
- }
109
- logger.warn(
110
- `[channels] ${path} version ${String(version)} not supported (expected 2, 3, or ${FILE_VERSION}); ignored`,
111
- )
81
+ logger.warn(`[channels] ${path} version ${String(version)} not supported (expected ${FILE_VERSION}); ignored`)
112
82
  return []
113
83
  }
114
84
 
@@ -130,59 +100,6 @@ export async function saveChannelSessions(
130
100
  }
131
101
  }
132
102
 
133
- // One-shot migration from v2 (sessionId only) to v3 (sessionId + sessionFile).
134
- // pi-coding-agent writes session files as `${ISO_TIMESTAMP}_${UUID}.jsonl`,
135
- // so we look for any file ending in `_${sessionId}.jsonl`. If a directory
136
- // scan fails we leave sessionFile undefined; the next reopen attempt will
137
- // fall back to a fresh session (the same broken behavior v2 had — but at
138
- // least the next successful create will populate sessionFile correctly and
139
- // we'll be migrated forward.)
140
- async function migrateV2Records(
141
- agentDir: string,
142
- v2Records: readonly (Omit<ChannelSessionRecord, 'sessionFile' | 'sessionId'> & { sessionId: string })[],
143
- logger: ChannelSessionsLogger,
144
- ): Promise<ChannelSessionRecord[]> {
145
- if (v2Records.length === 0) return []
146
- const sessionsDir = sessionsDirOf(agentDir)
147
- let entries: string[]
148
- try {
149
- entries = await readdir(sessionsDir)
150
- } catch {
151
- logger.warn(`[channels] could not scan ${sessionsDir} for v2→v3 migration; sessionFile left empty`)
152
- return v2Records.map((r) => ({ ...r }))
153
- }
154
- // pi-coding-agent writes files as `${ISO_TIMESTAMP}_${UUID}.jsonl` where
155
- // the ISO timestamp uses `-` (no `_`) and the UUID may contain `-`. Split
156
- // on the FIRST underscore so the trailing portion is the full UUID even
157
- // when the UUID contains hyphens.
158
- const bySessionIdSuffix = new Map<string, string>()
159
- for (const entry of entries) {
160
- if (!entry.endsWith('.jsonl')) continue
161
- const underscore = entry.indexOf('_')
162
- if (underscore < 0) continue
163
- const trailing = entry.slice(underscore + 1, -'.jsonl'.length)
164
- bySessionIdSuffix.set(trailing, entry)
165
- }
166
- return v2Records.map((r) => {
167
- const matched = bySessionIdSuffix.get(r.sessionId)
168
- if (matched === undefined) {
169
- logger.warn(
170
- `[channels] v2→v3: no session file matching *_${r.sessionId}.jsonl in ${sessionsDir}; ` +
171
- `sessionFile left empty (next inbound will create a fresh session for ${r.adapter}:${r.chat}:${r.thread ?? ''})`,
172
- )
173
- return { ...r }
174
- }
175
- return { ...r, sessionFile: matched }
176
- })
177
- }
178
-
179
- function migrateV3ToV4(v3Records: ChannelSessionRecord[], logger: ChannelSessionsLogger): ChannelSessionRecord[] {
180
- logger.info(
181
- `[channels] v3→v4: ${v3Records.length} record(s) migrated; first post-upgrade inbound will force fresh session`,
182
- )
183
- return v3Records.map((r) => ({ ...r, lastInboundAt: 0 }))
184
- }
185
-
186
103
  function dedupe(sessions: readonly ChannelSessionRecord[]): ChannelSessionRecord[] {
187
104
  const seen = new Map<string, ChannelSessionRecord>()
188
105
  for (const s of sessions) {
@@ -208,21 +125,6 @@ function isObject(v: unknown): v is Record<string, unknown> {
208
125
  return typeof v === 'object' && v !== null && !Array.isArray(v)
209
126
  }
210
127
 
211
- function isValidV2Record(
212
- v: unknown,
213
- ): v is Omit<ChannelSessionRecord, 'sessionFile' | 'sessionId'> & { sessionId: string } {
214
- if (!isObject(v)) return false
215
- const r = v as Record<string, unknown>
216
- return (
217
- typeof r.adapter === 'string' &&
218
- typeof r.workspace === 'string' &&
219
- typeof r.chat === 'string' &&
220
- (r.thread === null || typeof r.thread === 'string') &&
221
- typeof r.sessionId === 'string' &&
222
- Array.isArray(r.participants)
223
- )
224
- }
225
-
226
128
  function isValidRecord(v: unknown): v is ChannelSessionRecord {
227
129
  if (!isObject(v)) return false
228
130
  const r = v as Record<string, unknown>