chenbhao commited on
Commit
2c4e38d
·
1 Parent(s): 2e2419b

fix: goal can resuem by /resume

Browse files

问题根源:Goal 状态只存储在 AppState.goal(内存)中,会话重启时内存丢失。

解决方案:把 Goal 状态持久化到 JSONL transcript 中,resume 时恢复。

改动的文件

src/types/logs.ts
- 新增 GoalEntry 类型(包含 type: 'goal'、所有 Goal 字段)
- GoalEntry 加入 Entry 联合类型
- LogOption 新增 goal?: GoalEntry 字段

src/utils/sessionStorage.ts
- METADATA_TYPE_MARKERS 数组加入 'type:goal'(支持预-compact 边界扫描时恢复)
- Project 类新增 currentSessionGoal: GoalEntry | undefined 缓存
- reAppendSessionMetadata() 中 re-append currentSessionGoal(session 退出时重写最新状态)
- restoreSessionMetadata() 新增 goal?: GoalEntry 参数,会写到 currentSessionGoal 缓存
- appendEntry() 中的 type === 'goal' 分支(不需要,已由 saveGoal 直接写)
- 新增 saveGoal(goal: GoalEntry) 函数:立即写入 transcript + 缓存
- 新增 clearGoal() 函数:清空缓存(用于 /goal clear)
- loadTranscriptFile() 解析 goal 条目,返回值加入 goal
- loadFullLog、getLastSessionLog、loadAllLogsFromSessionFile、loadTranscriptFromFile 均透传 goal

src/utils/conversationRecovery.ts
- loadConversationForResume 返回值新增 goal?: GoalEntry

src/utils/sessionRestore.ts
- ResumeLoadResult 新增 goal?: GoalEntry
- ProcessedResume 中的 initialState 展开时包含 ...(result.goal && { goal: result.goal })

src/commands/goal/goal.tsx
- setGoal 辅助函数:每次状态变更后调用 saveGoal / clearGoal

src/tools/GoalCreateTool/GoalCreateTool.ts
- 创建 goal 后调用 saveGoal

src/tools/GoalUpdateTool/GoalUpdateTool.ts
- 更新 goal 后调用 saveGoal

src/commands/goal/goal.tsx CHANGED
@@ -11,6 +11,7 @@ import {
11
  formatGoalStatus,
12
  isGoalContinuationPrompt,
13
  } from '../../utils/goal.js'
 
14
  import { enqueue, removeByFilter } from '../../utils/messageQueueManager.js'
15
  import { renderToString } from '../../utils/staticRender.js'
16
 
@@ -147,7 +148,15 @@ function setGoal(
147
  setAppState: LocalJSXCommandContext['setAppState'],
148
  updater: (prev: Goal | undefined) => Goal | undefined,
149
  ): void {
150
- setAppState((prev: AppState) => ({ ...prev, goal: updater(prev.goal) } satisfies AppState))
 
 
 
 
 
 
 
 
151
  }
152
 
153
  function clearQueuedGoalContinuations(): void {
 
11
  formatGoalStatus,
12
  isGoalContinuationPrompt,
13
  } from '../../utils/goal.js'
14
+ import { clearGoal, saveGoal } from '../../utils/sessionStorage.js'
15
  import { enqueue, removeByFilter } from '../../utils/messageQueueManager.js'
16
  import { renderToString } from '../../utils/staticRender.js'
17
 
 
148
  setAppState: LocalJSXCommandContext['setAppState'],
149
  updater: (prev: Goal | undefined) => Goal | undefined,
150
  ): void {
151
+ setAppState((prev: AppState) => {
152
+ const newGoal = updater(prev.goal)
153
+ if (newGoal) {
154
+ saveGoal({ type: 'goal', ...newGoal })
155
+ } else {
156
+ clearGoal()
157
+ }
158
+ return { ...prev, goal: newGoal } satisfies AppState
159
+ })
160
  }
161
 
162
  function clearQueuedGoalContinuations(): void {
src/tools/GoalCreateTool/GoalCreateTool.ts CHANGED
@@ -9,6 +9,9 @@ import {
9
  formatGoalStatus,
10
  isGoalInactive,
11
  } from '../../utils/goal.js'
 
 
 
12
  import {
13
  CREATE_GOAL_TOOL_NAME,
14
  CREATE_GOAL_TOOL_PROMPT,
@@ -103,6 +106,17 @@ export const GoalCreateTool = buildTool({
103
  lastUpdatedAt: now,
104
  },
105
  }))
 
 
 
 
 
 
 
 
 
 
 
106
 
107
  return {
108
  data: {
 
9
  formatGoalStatus,
10
  isGoalInactive,
11
  } from '../../utils/goal.js'
12
+ import {
13
+ saveGoal,
14
+ } from '../../utils/sessionStorage.js'
15
  import {
16
  CREATE_GOAL_TOOL_NAME,
17
  CREATE_GOAL_TOOL_PROMPT,
 
106
  lastUpdatedAt: now,
107
  },
108
  }))
109
+ saveGoal({
110
+ type: 'goal',
111
+ id: goalId,
112
+ objective: objective.trim(),
113
+ status: 'pursuing',
114
+ startedAt: now,
115
+ startCostUSD: getTotalCost(),
116
+ startTokensUsed: getTotalTokensUsed(),
117
+ continuationCount: 0,
118
+ lastUpdatedAt: now,
119
+ })
120
 
121
  return {
122
  data: {
src/tools/GoalUpdateTool/GoalUpdateTool.ts CHANGED
@@ -2,6 +2,7 @@ import { z } from 'zod'
2
  import { buildTool, type ToolDef } from '../../Tool.js'
3
  import { lazySchema } from '../../utils/lazySchema.js'
4
  import { jsonStringify } from '../../utils/slowOperations.js'
 
5
  import {
6
  DESCRIPTION,
7
  UPDATE_GOAL_TOOL_NAME,
@@ -103,13 +104,13 @@ export const GoalUpdateTool = buildTool({
103
  }
104
 
105
  const now = Date.now()
106
- let updated = false
107
 
108
  const internalStatus =
109
  status === 'complete'
110
  ? ('achieved' as const)
111
  : ('blocked' as const)
112
 
 
113
  setAppState(prev => {
114
  const current = prev.goal
115
  if (
@@ -119,19 +120,19 @@ export const GoalUpdateTool = buildTool({
119
  ) {
120
  return prev
121
  }
122
- updated = true
 
 
 
 
 
123
  return {
124
  ...prev,
125
- goal: {
126
- ...current,
127
- status: internalStatus,
128
- lastReason: reason,
129
- lastUpdatedAt: now,
130
- },
131
  }
132
  })
133
 
134
- if (!updated) {
135
  return {
136
  data: {
137
  status: 'stale-goal' as const,
@@ -142,6 +143,11 @@ export const GoalUpdateTool = buildTool({
142
  }
143
  }
144
 
 
 
 
 
 
145
  return {
146
  data: {
147
  status,
 
2
  import { buildTool, type ToolDef } from '../../Tool.js'
3
  import { lazySchema } from '../../utils/lazySchema.js'
4
  import { jsonStringify } from '../../utils/slowOperations.js'
5
+ import { saveGoal } from '../../utils/sessionStorage.js'
6
  import {
7
  DESCRIPTION,
8
  UPDATE_GOAL_TOOL_NAME,
 
104
  }
105
 
106
  const now = Date.now()
 
107
 
108
  const internalStatus =
109
  status === 'complete'
110
  ? ('achieved' as const)
111
  : ('blocked' as const)
112
 
113
+ let updatedGoal: typeof goal | null = null
114
  setAppState(prev => {
115
  const current = prev.goal
116
  if (
 
120
  ) {
121
  return prev
122
  }
123
+ updatedGoal = {
124
+ ...current,
125
+ status: internalStatus,
126
+ lastReason: reason,
127
+ lastUpdatedAt: now,
128
+ }
129
  return {
130
  ...prev,
131
+ goal: updatedGoal,
 
 
 
 
 
132
  }
133
  })
134
 
135
+ if (!updatedGoal) {
136
  return {
137
  data: {
138
  status: 'stale-goal' as const,
 
143
  }
144
  }
145
 
146
+ saveGoal({
147
+ type: 'goal',
148
+ ...updatedGoal,
149
+ })
150
+
151
  return {
152
  data: {
153
  status,
src/types/logs.ts CHANGED
@@ -50,6 +50,7 @@ export type LogOption = {
50
  mode?: 'coordinator' | 'normal' // Session mode for coordinator/normal detection
51
  worktreeSession?: PersistedWorktreeSession | null // Worktree state at session end (null = exited, undefined = never entered)
52
  contentReplacements?: ContentReplacementRecord[] // Replacement decisions for resume reconstruction
 
53
  }
54
 
55
  export type SummaryMessage = {
@@ -84,6 +85,27 @@ export type LastPromptMessage = {
84
  lastPrompt: string
85
  }
86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  /**
88
  * Periodic fork-generated summary of what the agent is currently doing.
89
  * Written every min(5 steps, 2min) by forking the main thread mid-turn so
@@ -315,6 +337,7 @@ export type Entry =
315
  | ContentReplacementEntry
316
  | ContextCollapseCommitEntry
317
  | ContextCollapseSnapshotEntry
 
318
 
319
  export function sortLogs(logs: LogOption[]): LogOption[] {
320
  return logs.sort((a, b) => {
 
50
  mode?: 'coordinator' | 'normal' // Session mode for coordinator/normal detection
51
  worktreeSession?: PersistedWorktreeSession | null // Worktree state at session end (null = exited, undefined = never entered)
52
  contentReplacements?: ContentReplacementRecord[] // Replacement decisions for resume reconstruction
53
+ goal?: GoalEntry // /goal state for session resume
54
  }
55
 
56
  export type SummaryMessage = {
 
85
  lastPrompt: string
86
  }
87
 
88
+ /** /goal state persisted to the transcript for resume. Last-wins. */
89
+ export type GoalEntry = {
90
+ type: 'goal'
91
+ sessionId: UUID
92
+ id: string
93
+ objective: string
94
+ status:
95
+ | 'pursuing'
96
+ | 'paused'
97
+ | 'achieved'
98
+ | 'blocked'
99
+ | 'usage-limited'
100
+ | 'budget-limited'
101
+ startedAt: number
102
+ startCostUSD: number
103
+ startTokensUsed: number
104
+ continuationCount: number
105
+ lastReason?: string
106
+ lastUpdatedAt: number
107
+ }
108
+
109
  /**
110
  * Periodic fork-generated summary of what the agent is currently doing.
111
  * Written every min(5 steps, 2min) by forking the main thread mid-turn so
 
337
  | ContentReplacementEntry
338
  | ContextCollapseCommitEntry
339
  | ContextCollapseSnapshotEntry
340
+ | GoalEntry
341
 
342
  export function sortLogs(logs: LogOption[]): LogOption[] {
343
  return logs.sort((a, b) => {
src/utils/conversationRecovery.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
  AttributionSnapshotMessage,
9
  ContextCollapseCommitEntry,
10
  ContextCollapseSnapshotEntry,
 
11
  LogOption,
12
  PersistedWorktreeSession,
13
  SerializedMessage,
@@ -478,6 +479,7 @@ export async function loadConversationForResume(
478
  prRepository?: string
479
  // Full path to the session file (for cross-directory resume)
480
  fullPath?: string
 
481
  } | null> {
482
  try {
483
  let log: LogOption | null = null
@@ -589,6 +591,7 @@ export async function loadConversationForResume(
589
  prRepository: log?.prRepository,
590
  // Include full path for cross-directory resume
591
  fullPath: log?.fullPath,
 
592
  }
593
  } catch (error) {
594
  logError(error as Error)
 
8
  AttributionSnapshotMessage,
9
  ContextCollapseCommitEntry,
10
  ContextCollapseSnapshotEntry,
11
+ GoalEntry,
12
  LogOption,
13
  PersistedWorktreeSession,
14
  SerializedMessage,
 
479
  prRepository?: string
480
  // Full path to the session file (for cross-directory resume)
481
  fullPath?: string
482
+ goal?: GoalEntry
483
  } | null> {
484
  try {
485
  let log: LogOption | null = null
 
591
  prRepository: log?.prRepository,
592
  // Include full path for cross-directory resume
593
  fullPath: log?.fullPath,
594
+ goal: log?.goal,
595
  }
596
  } catch (error) {
597
  logError(error as Error)
src/utils/sessionRestore.ts CHANGED
@@ -25,6 +25,7 @@ import type {
25
  AttributionSnapshotMessage,
26
  ContextCollapseCommitEntry,
27
  ContextCollapseSnapshotEntry,
 
28
  PersistedWorktreeSession,
29
  } from '../types/logs.js'
30
  import type { Message } from '../types/message.js'
@@ -312,6 +313,7 @@ type ResumeLoadResult = {
312
  prNumber?: number
313
  prUrl?: string
314
  prRepository?: string
 
315
  }
316
 
317
  /**
@@ -546,6 +548,7 @@ export async function processResumedConversation(
546
  ...(restoredAttribution && { attribution: restoredAttribution }),
547
  ...(standaloneAgentContext && { standaloneAgentContext }),
548
  agentDefinitions: refreshedAgentDefs,
 
549
  },
550
  }
551
  }
 
25
  AttributionSnapshotMessage,
26
  ContextCollapseCommitEntry,
27
  ContextCollapseSnapshotEntry,
28
+ GoalEntry,
29
  PersistedWorktreeSession,
30
  } from '../types/logs.js'
31
  import type { Message } from '../types/message.js'
 
313
  prNumber?: number
314
  prUrl?: string
315
  prRepository?: string
316
+ goal?: GoalEntry
317
  }
318
 
319
  /**
 
548
  ...(restoredAttribution && { attribution: restoredAttribution }),
549
  ...(standaloneAgentContext && { standaloneAgentContext }),
550
  agentDefinitions: refreshedAgentDefs,
551
+ ...(result.goal && { goal: result.goal }),
552
  },
553
  }
554
  }
src/utils/sessionStorage.ts CHANGED
@@ -41,7 +41,7 @@ import {
41
  asSessionId,
42
  type SessionId,
43
  } from '../types/ids.js'
44
- import type { AttributionSnapshotMessage } from '../types/logs.js'
45
  import {
46
  type ContentReplacementEntry,
47
  type ContextCollapseCommitEntry,
@@ -545,6 +545,8 @@ class Project {
545
  currentSessionPrNumber: number | undefined
546
  currentSessionPrUrl: string | undefined
547
  currentSessionPrRepository: string | undefined
 
 
548
 
549
  sessionFile: string | null = null
550
  // Entries buffered while sessionFile is null. Flushed by materializeSessionFile
@@ -836,6 +838,12 @@ class Project {
836
  timestamp: new Date().toISOString(),
837
  })
838
  }
 
 
 
 
 
 
839
  }
840
 
841
  async flush(): Promise<void> {
@@ -2307,6 +2315,7 @@ export async function loadTranscriptFromFile(
2307
  leafUuids,
2308
  contentReplacements,
2309
  worktreeStates,
 
2310
  } = await loadTranscriptFile(filePath)
2311
 
2312
  if (messages.size === 0) {
@@ -2352,6 +2361,7 @@ export async function loadTranscriptFromFile(
2352
  worktreeSession: worktreeStates.has(sessionId)
2353
  ? worktreeStates.get(sessionId)
2354
  : undefined,
 
2355
  }
2356
  }
2357
 
@@ -2766,6 +2776,7 @@ export function restoreSessionMetadata(meta: {
2766
  prNumber?: number
2767
  prUrl?: string
2768
  prRepository?: string
 
2769
  }): void {
2770
  const project = getProject()
2771
  // ??= so --name (cacheSessionTitle) wins over the resumed
@@ -2782,6 +2793,7 @@ export function restoreSessionMetadata(meta: {
2782
  project.currentSessionPrNumber = meta.prNumber
2783
  if (meta.prUrl) project.currentSessionPrUrl = meta.prUrl
2784
  if (meta.prRepository) project.currentSessionPrRepository = meta.prRepository
 
2785
  }
2786
 
2787
  /**
@@ -2919,6 +2931,34 @@ export function saveWorktreeState(
2919
  }
2920
  }
2921
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2922
  /**
2923
  * Extracts the session ID from a log.
2924
  * For lite logs, uses the sessionId field directly.
@@ -2978,6 +3018,7 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
2978
  contextCollapseCommits,
2979
  contextCollapseSnapshot,
2980
  leafUuids,
 
2981
  } = await loadTranscriptFile(sessionFile)
2982
 
2983
  if (messages.size === 0) {
@@ -3048,6 +3089,7 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
3048
  sessionId && contextCollapseSnapshot?.sessionId === sessionId
3049
  ? contextCollapseSnapshot
3050
  : undefined,
 
3051
  }
3052
  } catch {
3053
  // If loading fails, return the original log
@@ -3120,6 +3162,7 @@ const METADATA_TYPE_MARKERS = [
3120
  '"type":"mode"',
3121
  '"type":"worktree-state"',
3122
  '"type":"pr-link"',
 
3123
  ]
3124
  const METADATA_MARKER_BUFS = METADATA_TYPE_MARKERS.map(m => Buffer.from(m))
3125
  // Longest marker is 22 bytes; +1 for leading `{` = 23.
@@ -3492,6 +3535,8 @@ export async function loadTranscriptFile(
3492
  contextCollapseCommits: ContextCollapseCommitEntry[]
3493
  contextCollapseSnapshot: ContextCollapseSnapshotEntry | undefined
3494
  leafUuids: Set<UUID>
 
 
3495
  }> {
3496
  const messages = new Map<UUID, TranscriptMessage>()
3497
  const summaries = new Map<UUID, string>()
@@ -3516,6 +3561,8 @@ export async function loadTranscriptFile(
3516
  const contextCollapseCommits: ContextCollapseCommitEntry[] = []
3517
  // Last-wins — later entries supersede.
3518
  let contextCollapseSnapshot: ContextCollapseSnapshotEntry | undefined
 
 
3519
 
3520
  try {
3521
  // For large transcripts, avoid materializing megabytes of stale content.
@@ -3607,6 +3654,8 @@ export async function loadTranscriptFile(
3607
  prNumbers.set(entry.sessionId, entry.prNumber)
3608
  prUrls.set(entry.sessionId, entry.prUrl)
3609
  prRepositories.set(entry.sessionId, entry.prRepository)
 
 
3610
  }
3611
  }
3612
  }
@@ -3675,6 +3724,8 @@ export async function loadTranscriptFile(
3675
  prNumbers.set(entry.sessionId, entry.prNumber)
3676
  prUrls.set(entry.sessionId, entry.prUrl)
3677
  prRepositories.set(entry.sessionId, entry.prRepository)
 
 
3678
  } else if (entry.type === 'file-history-snapshot') {
3679
  fileHistorySnapshots.set(entry.messageId, entry)
3680
  } else if (entry.type === 'attribution-snapshot') {
@@ -3809,6 +3860,7 @@ export async function loadTranscriptFile(
3809
  contextCollapseCommits,
3810
  contextCollapseSnapshot,
3811
  leafUuids,
 
3812
  }
3813
  }
3814
 
@@ -3827,6 +3879,7 @@ async function loadSessionFile(sessionId: UUID): Promise<{
3827
  contentReplacements: Map<UUID, ContentReplacementRecord[]>
3828
  contextCollapseCommits: ContextCollapseCommitEntry[]
3829
  contextCollapseSnapshot: ContextCollapseSnapshotEntry | undefined
 
3830
  }> {
3831
  const sessionFile = join(
3832
  getSessionProjectDir() ?? getProjectDir(getOriginalCwd()),
@@ -3882,6 +3935,7 @@ export async function getLastSessionLog(
3882
  contentReplacements,
3883
  contextCollapseCommits,
3884
  contextCollapseSnapshot,
 
3885
  } = await loadSessionFile(sessionId)
3886
  if (messages.size === 0) return null
3887
  // Prime getSessionMessages cache so recordTranscript (called after REPL
@@ -3928,6 +3982,7 @@ export async function getLastSessionLog(
3928
  contextCollapseSnapshot?.sessionId === sessionId
3929
  ? contextCollapseSnapshot
3930
  : undefined,
 
3931
  }
3932
  }
3933
 
@@ -4615,6 +4670,7 @@ export async function loadAllLogsFromSessionFile(
4615
  attributionSnapshots,
4616
  contentReplacements,
4617
  leafUuids,
 
4618
  } = await loadTranscriptFile(sessionFile, { keepAllLeaves: true })
4619
 
4620
  if (messages.size === 0) return []
@@ -4687,6 +4743,7 @@ export async function loadAllLogsFromSessionFile(
4687
  chain,
4688
  ),
4689
  contentReplacements: contentReplacements.get(sessionId) ?? [],
 
4690
  })
4691
  }
4692
 
 
41
  asSessionId,
42
  type SessionId,
43
  } from '../types/ids.js'
44
+ import type { AttributionSnapshotMessage, GoalEntry } from '../types/logs.js'
45
  import {
46
  type ContentReplacementEntry,
47
  type ContextCollapseCommitEntry,
 
545
  currentSessionPrNumber: number | undefined
546
  currentSessionPrUrl: string | undefined
547
  currentSessionPrRepository: string | undefined
548
+ // /goal state — re-appended on exit so --resume restores it
549
+ currentSessionGoal: GoalEntry | undefined
550
 
551
  sessionFile: string | null = null
552
  // Entries buffered while sessionFile is null. Flushed by materializeSessionFile
 
838
  timestamp: new Date().toISOString(),
839
  })
840
  }
841
+ if (this.currentSessionGoal) {
842
+ appendEntryToFile(this.sessionFile, {
843
+ ...this.currentSessionGoal,
844
+ sessionId,
845
+ })
846
+ }
847
  }
848
 
849
  async flush(): Promise<void> {
 
2315
  leafUuids,
2316
  contentReplacements,
2317
  worktreeStates,
2318
+ goal,
2319
  } = await loadTranscriptFile(filePath)
2320
 
2321
  if (messages.size === 0) {
 
2361
  worktreeSession: worktreeStates.has(sessionId)
2362
  ? worktreeStates.get(sessionId)
2363
  : undefined,
2364
+ goal,
2365
  }
2366
  }
2367
 
 
2776
  prNumber?: number
2777
  prUrl?: string
2778
  prRepository?: string
2779
+ goal?: GoalEntry
2780
  }): void {
2781
  const project = getProject()
2782
  // ??= so --name (cacheSessionTitle) wins over the resumed
 
2793
  project.currentSessionPrNumber = meta.prNumber
2794
  if (meta.prUrl) project.currentSessionPrUrl = meta.prUrl
2795
  if (meta.prRepository) project.currentSessionPrRepository = meta.prRepository
2796
+ if (meta.goal) project.currentSessionGoal = meta.goal
2797
  }
2798
 
2799
  /**
 
2931
  }
2932
  }
2933
 
2934
+ /**
2935
+ * Persist /goal state to the transcript for --resume.
2936
+ * Written eagerly on every goal change so the transcript always reflects
2937
+ * the latest state. Last-wins on restore — the freshest entry wins.
2938
+ */
2939
+ export function saveGoal(goal: GoalEntry): void {
2940
+ const project = getProject()
2941
+ project.currentSessionGoal = goal
2942
+ // Write immediately so the transcript reflects the latest state at all times.
2943
+ // reAppendSessionMetadata also re-appends on exit, but without an immediate
2944
+ // write a crash after goal change but before exit would lose the update.
2945
+ if (project.sessionFile) {
2946
+ appendEntryToFile(project.sessionFile, {
2947
+ ...goal,
2948
+ sessionId: getSessionId(),
2949
+ })
2950
+ }
2951
+ }
2952
+
2953
+ /**
2954
+ * Clear the cached goal state. Called when /goal clear removes the goal,
2955
+ * or when restoreSessionMetadata runs for a session that had no goal.
2956
+ */
2957
+ export function clearGoal(): void {
2958
+ const project = getProject()
2959
+ project.currentSessionGoal = undefined
2960
+ }
2961
+
2962
  /**
2963
  * Extracts the session ID from a log.
2964
  * For lite logs, uses the sessionId field directly.
 
3018
  contextCollapseCommits,
3019
  contextCollapseSnapshot,
3020
  leafUuids,
3021
+ goal,
3022
  } = await loadTranscriptFile(sessionFile)
3023
 
3024
  if (messages.size === 0) {
 
3089
  sessionId && contextCollapseSnapshot?.sessionId === sessionId
3090
  ? contextCollapseSnapshot
3091
  : undefined,
3092
+ goal,
3093
  }
3094
  } catch {
3095
  // If loading fails, return the original log
 
3162
  '"type":"mode"',
3163
  '"type":"worktree-state"',
3164
  '"type":"pr-link"',
3165
+ '"type":"goal"',
3166
  ]
3167
  const METADATA_MARKER_BUFS = METADATA_TYPE_MARKERS.map(m => Buffer.from(m))
3168
  // Longest marker is 22 bytes; +1 for leading `{` = 23.
 
3535
  contextCollapseCommits: ContextCollapseCommitEntry[]
3536
  contextCollapseSnapshot: ContextCollapseSnapshotEntry | undefined
3537
  leafUuids: Set<UUID>
3538
+ /** Last-wins goal entry for this session */
3539
+ goal?: GoalEntry
3540
  }> {
3541
  const messages = new Map<UUID, TranscriptMessage>()
3542
  const summaries = new Map<UUID, string>()
 
3561
  const contextCollapseCommits: ContextCollapseCommitEntry[] = []
3562
  // Last-wins — later entries supersede.
3563
  let contextCollapseSnapshot: ContextCollapseSnapshotEntry | undefined
3564
+ // Last-wins — later goal entries supersede.
3565
+ let goal: GoalEntry | undefined
3566
 
3567
  try {
3568
  // For large transcripts, avoid materializing megabytes of stale content.
 
3654
  prNumbers.set(entry.sessionId, entry.prNumber)
3655
  prUrls.set(entry.sessionId, entry.prUrl)
3656
  prRepositories.set(entry.sessionId, entry.prRepository)
3657
+ } else if (entry.type === 'goal' && entry.sessionId) {
3658
+ goal = entry
3659
  }
3660
  }
3661
  }
 
3724
  prNumbers.set(entry.sessionId, entry.prNumber)
3725
  prUrls.set(entry.sessionId, entry.prUrl)
3726
  prRepositories.set(entry.sessionId, entry.prRepository)
3727
+ } else if (entry.type === 'goal' && entry.sessionId) {
3728
+ goal = entry
3729
  } else if (entry.type === 'file-history-snapshot') {
3730
  fileHistorySnapshots.set(entry.messageId, entry)
3731
  } else if (entry.type === 'attribution-snapshot') {
 
3860
  contextCollapseCommits,
3861
  contextCollapseSnapshot,
3862
  leafUuids,
3863
+ goal,
3864
  }
3865
  }
3866
 
 
3879
  contentReplacements: Map<UUID, ContentReplacementRecord[]>
3880
  contextCollapseCommits: ContextCollapseCommitEntry[]
3881
  contextCollapseSnapshot: ContextCollapseSnapshotEntry | undefined
3882
+ goal?: GoalEntry
3883
  }> {
3884
  const sessionFile = join(
3885
  getSessionProjectDir() ?? getProjectDir(getOriginalCwd()),
 
3935
  contentReplacements,
3936
  contextCollapseCommits,
3937
  contextCollapseSnapshot,
3938
+ goal,
3939
  } = await loadSessionFile(sessionId)
3940
  if (messages.size === 0) return null
3941
  // Prime getSessionMessages cache so recordTranscript (called after REPL
 
3982
  contextCollapseSnapshot?.sessionId === sessionId
3983
  ? contextCollapseSnapshot
3984
  : undefined,
3985
+ goal,
3986
  }
3987
  }
3988
 
 
4670
  attributionSnapshots,
4671
  contentReplacements,
4672
  leafUuids,
4673
+ goal,
4674
  } = await loadTranscriptFile(sessionFile, { keepAllLeaves: true })
4675
 
4676
  if (messages.size === 0) return []
 
4743
  chain,
4744
  ),
4745
  contentReplacements: contentReplacements.get(sessionId) ?? [],
4746
+ goal,
4747
  })
4748
  }
4749