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
|
@@ -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) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
@@ -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: {
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
return {
|
| 124 |
...prev,
|
| 125 |
-
goal:
|
| 126 |
-
...current,
|
| 127 |
-
status: internalStatus,
|
| 128 |
-
lastReason: reason,
|
| 129 |
-
lastUpdatedAt: now,
|
| 130 |
-
},
|
| 131 |
}
|
| 132 |
})
|
| 133 |
|
| 134 |
-
if (!
|
| 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,
|
|
@@ -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) => {
|
|
@@ -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)
|
|
@@ -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 |
}
|
|
@@ -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 |
|