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
@@ -9,7 +9,7 @@ const GRAPHQL_ENDPOINT = `${GITHUB_API_BASE}/graphql`
9
9
  // carry more, so the resolver paginates until it matches the root comment id
10
10
  // or exhausts the pages — stopping early on a 404-equivalent (thread absent)
11
11
  // rather than fabricating a node id.
12
- const THREADS_QUERY = `query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{id isResolved comments(first:1){nodes{databaseId author{login}}}}}}}}`
12
+ const THREADS_QUERY = `query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{id isResolved comments(first:1){nodes{databaseId author{__typename login}}}}}}}}`
13
13
 
14
14
  const RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{id isResolved}}}`
15
15
 
@@ -18,6 +18,7 @@ type ReviewThreadNode = {
18
18
  isResolved: boolean
19
19
  rootCommentId: number | null
20
20
  rootAuthorLogin: string | null
21
+ rootAuthorIsBot: boolean
21
22
  }
22
23
 
23
24
  type ThreadLookup =
@@ -66,7 +67,7 @@ export function createGithubReviewThreadResolver(deps: {
66
67
  const thread = lookup.thread
67
68
  // The load-bearing guard: only the bot may resolve the bot's own thread.
68
69
  // Resolving a human reviewer's thread would erase their open question.
69
- if (thread.rootAuthorLogin !== selfLogin) {
70
+ if (!isSelfAuthor(thread, selfLogin)) {
70
71
  return {
71
72
  ok: false,
72
73
  error: `refusing to resolve thread authored by @${thread.rootAuthorLogin ?? 'unknown'} (not @${selfLogin})`,
@@ -79,6 +80,29 @@ export function createGithubReviewThreadResolver(deps: {
79
80
  }
80
81
  }
81
82
 
83
+ // A GitHub App's own login differs across the two APIs this guard straddles:
84
+ // REST `getSelf` returns `slug[bot]` (selfLogin) but GraphQL's `Bot` author node
85
+ // returns the bare `slug` (rootAuthorLogin). Strict `===` thus refused the App's
86
+ // OWN thread (production: "refusing to resolve thread authored by @typeey (not
87
+ // @typeey[bot])"). The bare-slug match is gated on the GraphQL author actually
88
+ // being a `Bot`: a human `User` can legitimately own the bare slug as a login
89
+ // (e.g. the user `typeey` exists alongside the App `typeey[bot]`), so a User
90
+ // author must still match `selfLogin` exactly — otherwise the suffix-strip would
91
+ // let the bot close a human reviewer's thread, defeating the guard above.
92
+ const BOT_LOGIN_SUFFIX = '[bot]'
93
+
94
+ function isSelfAuthor(thread: ReviewThreadNode, selfLogin: string): boolean {
95
+ if (thread.rootAuthorLogin === null) return false
96
+ if (thread.rootAuthorIsBot) {
97
+ return normalizeBotLogin(thread.rootAuthorLogin) === normalizeBotLogin(selfLogin)
98
+ }
99
+ return thread.rootAuthorLogin === selfLogin
100
+ }
101
+
102
+ function normalizeBotLogin(login: string): string {
103
+ return login.endsWith(BOT_LOGIN_SUFFIX) ? login.slice(0, -BOT_LOGIN_SUFFIX.length) : login
104
+ }
105
+
82
106
  type ResolveTarget = { owner: string; repo: string; prNumber: number; rootCommentId: number }
83
107
 
84
108
  function parseTarget(req: ReviewThreadResolveRequest): ResolveTarget | null {
@@ -104,6 +128,69 @@ function parseDecimalId(value: string | undefined): number | null {
104
128
  }
105
129
 
106
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> {
107
194
  let after: string | null = null
108
195
  for (;;) {
109
196
  let response: Response
@@ -124,11 +211,8 @@ async function findThread(fetchImpl: typeof fetch, token: string, target: Resolv
124
211
  }
125
212
  const parsed = await parseThreadsPage(response)
126
213
  if (parsed.kind === 'error') return { kind: 'error', result: parsed.result }
127
-
128
- for (const node of parsed.nodes) {
129
- if (node.rootCommentId === target.rootCommentId) return { kind: 'found', thread: node }
130
- }
131
- 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' }
132
216
  after = parsed.endCursor
133
217
  }
134
218
  }
@@ -175,6 +259,7 @@ async function parseThreadsPage(response: Response): Promise<ThreadsPage> {
175
259
  isResolved: n.isResolved,
176
260
  rootCommentId: root?.databaseId ?? null,
177
261
  rootAuthorLogin: root?.author?.login ?? null,
262
+ rootAuthorIsBot: root?.author?.__typename === 'Bot',
178
263
  }
179
264
  })
180
265
  return { kind: 'ok', nodes, hasNextPage: connection.pageInfo.hasNextPage, endCursor: connection.pageInfo.endCursor }
@@ -231,7 +316,7 @@ type GraphqlThreadsResponse = {
231
316
  nodes: Array<{
232
317
  id: string
233
318
  isResolved: boolean
234
- comments: { nodes: Array<{ databaseId?: number; author?: { login?: string } }> }
319
+ comments: { nodes: Array<{ databaseId?: number; author?: { __typename?: string; login?: string } }> }
235
320
  }>
236
321
  }
237
322
  }
@@ -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,91 @@
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
+ // Only consulted by the caller when thread!=null (a review thread). Bare
28
+ // "resolved" is intentionally NOT here — it collides with the warn-tier "looks
29
+ // resolved?"; resolve claims must carry a definite marker (marked/that/this/
30
+ // thanks) or a verify clause.
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
+ /\bclosing this out\b/,
37
+ /\bconfirmed fixed\b/,
38
+ // verify clause + a fix/resolve verb, allowing a short gap ("verified at <sha>, that fixes it").
39
+ /\b(verified|confirmed)\b[^.!?]*\b(fix(es|ed)|resolv)/,
40
+ /\b(thanks,?|fixed,?) (looks )?resolved\b/,
41
+ ]
42
+
43
+ // Casual phrasing that might be chatter, not a formal close-out: allow + nudge.
44
+ const WARN: readonly RegExp[] = [
45
+ /\blgtm\b/,
46
+ /\blooks good\b/,
47
+ /\blooks fine\b/,
48
+ /\bseems fine\b/,
49
+ /\bshould be (fine|good)\b/,
50
+ /\bneeds changes\b/,
51
+ /\bstill needs work\b/,
52
+ /\blooks resolved\b/,
53
+ /\bseems resolved\b/,
54
+ ]
55
+
56
+ // Negation / future-intent / past-reference markers DEMOTE a positive match to
57
+ // ignore. Blocking "I haven't approved" / "I'll approve" / "approved it earlier"
58
+ // (answering a question) is the worst false-positive class, so it is checked first.
59
+ const DEMOTE_TO_IGNORE: readonly RegExp[] = [
60
+ /\b(haven'?t|have not|did ?n'?t|did not|not yet|never)\b[^.!?]*\b(approv|request|resolv|block)/,
61
+ /\b(can'?t|cannot|won'?t|will not|wouldn'?t)\b[^.!?]*\b(approv|request|resolv|block)/,
62
+ /\bnot (approved|resolved|blocked|requesting)\b/,
63
+ /\b(i'?ll|i will|going to|gonna|about to|planning to)\b[^.!?]*\b(approv|review|request|resolv)/,
64
+ /\b(approved|resolved|requested changes)\b[^.!?]*\b(earlier|already|yesterday|before|last (review|time)|previously)\b/,
65
+ ]
66
+
67
+ export function classifyReviewClaim(rawText: string): ReviewClaim {
68
+ const text = normalize(rawText)
69
+ if (text === '') return 'ignore'
70
+
71
+ if (DEMOTE_TO_IGNORE.some((re) => re.test(text))) return 'ignore'
72
+
73
+ // Block-tier wins over warn-tier: an unambiguous "approved" in a casual message
74
+ // is still a formal claim.
75
+ if (BLOCK_APPROVE.some((re) => re.test(text))) return 'block-approve'
76
+ if (BLOCK_REQUEST_CHANGES.some((re) => re.test(text))) return 'block-request-changes'
77
+ if (BLOCK_RESOLVE.some((re) => re.test(text))) return 'block-resolve'
78
+ if (WARN.some((re) => re.test(text))) return 'warn'
79
+ return 'ignore'
80
+ }
81
+
82
+ // Strips markdown/emoji noise so "**Approved!**" and "approved" classify alike,
83
+ // keeping apostrophes + sentence punctuation that the negation regexes rely on.
84
+ function normalize(text: string): string {
85
+ return text
86
+ .toLowerCase()
87
+ .replace(/[*_`~#>]/g, ' ')
88
+ .replace(/[^\p{L}\p{N}\s'.!?]/gu, ' ')
89
+ .replace(/\s+/g, ' ')
90
+ .trim()
91
+ }
@@ -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>