Skip to content

Commit 1637232

Browse files
universe-hcyclaude
andcommitted
feat: push/pop 上下文栈跨会话持久化 + --list 预览取用户指令 + pop进度条
- 栈随 transcript 持久化(last-wins push-stack entry),--resume 后仍能 /pop - resume 时 projectPushStackOntoMessages 重新投影,失效标记透明丢弃并通知 - /push --list 预览改取用户最近指令,避免显示空/合成占位消息(No response requested.) - 更新 push-pop-context-stack.md:跨会话持久化由非目标转为目标,同步预览语义 - /pop 时显示进度条以及前后上下文token数变化 Co-Authored-By: claude-opus-4-8[1m] <noreply@anthropic.com>
1 parent 1245157 commit 1637232

10 files changed

Lines changed: 504 additions & 14 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
| **🎯 Goal 持续驱动** | `/goal <objective>` 设定目标后,自动跨轮驱动 agent 直至完成;带 token budget、completion/blocked audit、`pause`/`resume`/`continue`/`clear` 子命令,网络中断自动暂停 | 源码 [`commands/goal/`](./src/commands/goal/) · [`services/goal/`](./src/services/goal/) |
2222
| **📦 Artifacts(HTML 上传)** | 复刻 Anthropic 官方 Artifacts:模型把 HTML/数据看板/报告上传到公开 URL(7d/30d 自动过期),`/artifacts` 命令集中管理,Cloudflare Worker + R2 完全开源、可自托管 | [8 小时复刻报告](./docs/blog/2026-06-20-cloud-artifacts-8h-recap.md) · [在线 demo](https://cloud-artifacts.claude-code-best.win/30d/c2jfwi3E-y3fTZ1ors-KE.html) |
2323
| **🧠 Ultracode 多 Agent 编排** | `/ultracode` 注入 workflow 编排手册 + `Workflow` 工具跑确定性 JS 脚本(`agent`/`pipeline`/`parallel`/`phase`)+ `/workflows` 双栏监控面板;支持 journal 重放、token budget、并发 cap | [文档](https://ccb.agent-aura.top/docs/features/workflow-scripts) |
24+
| **🌿 Push/Pop 上下文栈** | `/push` 开一个继承完整上下文的讨论旁支,`/pop` 把讨论蒸馏成结构化 digest 回卷主线——讨论噪音不污染主线、只留结论;支持嵌套、`--to #N` 跨层弹出、`--list` 零成本列栈、栈感知 auto-compact 三选(`FEATURE_PUSH_POP=1`| [设计文档](./docs/features/push-pop-context-stack.md) · 源码 [`commands/push/`](./src/commands/push/) · [`commands/pop/`](./src/commands/pop/) |
2425
| **Claude 群控技术** | Pipe IPC 多实例协作:同机 main/sub 自动编排 + LAN 跨机器零配置发现与通讯,`/pipes` 选择面板 + `Shift+↓` 交互 + 消息广播路由 | [Pipe IPC](https://ccb.agent-aura.top/docs/features/uds-inbox) / [LAN](https://ccb.agent-aura.top/docs/features/lan-pipes) |
2526
| **ACP 协议一等一支持** | 支持接入 Zed、Cursor 等 IDE,支持会话恢复、Skills、权限桥接 | [文档](https://ccb.agent-aura.top/docs/features/acp-zed) |
2627
| **Remote Control 私有部署** | Docker 自托管远程界面, 可以手机上看 CC | [文档](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) |

docs/features/push-pop-context-stack.md

Lines changed: 190 additions & 0 deletions
Large diffs are not rendered by default.

src/commands/__tests__/pushpop.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,79 @@ describe('push stack state', () => {
126126
})
127127
})
128128

129+
describe('projectPushStackOntoMessages', () => {
130+
const uuid = (n: number) =>
131+
`00000000-0000-0000-0000-${String(n).padStart(12, '0')}` as `${string}-${string}-${string}-${string}-${string}`
132+
const marker = (id: string, n: number) => ({
133+
id,
134+
messageUuid: uuid(n),
135+
note: id,
136+
timestamp: 0,
137+
anchorPreview: '',
138+
})
139+
const userMsg = (n: number) =>
140+
({
141+
type: 'user',
142+
uuid: uuid(n),
143+
}) as unknown as import('../../types/message.js').Message
144+
const boundary = (n: number) =>
145+
({
146+
type: 'system',
147+
subtype: 'compact_boundary',
148+
uuid: uuid(n),
149+
}) as unknown as import('../../types/message.js').Message
150+
151+
test('keeps markers whose anchor is still in the active view', async () => {
152+
const { projectPushStackOntoMessages } = await import(
153+
'../../services/pushStack/state.js'
154+
)
155+
const { validMarkers, droppedCount } = projectPushStackOntoMessages(
156+
[marker('a', 1), marker('b', 2)],
157+
[userMsg(1), userMsg(2)],
158+
)
159+
expect(validMarkers.map(m => m.id)).toEqual(['a', 'b'])
160+
expect(droppedCount).toBe(0)
161+
})
162+
163+
test('drops markers carried off before a compact boundary and counts them', async () => {
164+
const { projectPushStackOntoMessages } = await import(
165+
'../../services/pushStack/state.js'
166+
)
167+
// Boundary at index 1 → active view is [boundary, msg#3]; the pre-boundary
168+
// anchor #1 is gone, mirroring what /pop would reject.
169+
const { validMarkers, droppedCount } = projectPushStackOntoMessages(
170+
[marker('old', 1), marker('live', 3)],
171+
[userMsg(1), boundary(9), userMsg(3)],
172+
)
173+
expect(validMarkers.map(m => m.id)).toEqual(['live'])
174+
expect(droppedCount).toBe(1)
175+
})
176+
177+
test('empty stack returns empty with zero dropped', async () => {
178+
const { projectPushStackOntoMessages } = await import(
179+
'../../services/pushStack/state.js'
180+
)
181+
const { validMarkers, droppedCount } = projectPushStackOntoMessages(
182+
[],
183+
[userMsg(1)],
184+
)
185+
expect(validMarkers).toEqual([])
186+
expect(droppedCount).toBe(0)
187+
})
188+
189+
test('all markers invalid returns droppedCount = N', async () => {
190+
const { projectPushStackOntoMessages } = await import(
191+
'../../services/pushStack/state.js'
192+
)
193+
const { validMarkers, droppedCount } = projectPushStackOntoMessages(
194+
[marker('a', 1), marker('b', 2)],
195+
[userMsg(5)],
196+
)
197+
expect(validMarkers).toEqual([])
198+
expect(droppedCount).toBe(2)
199+
})
200+
})
201+
129202
describe('DIGEST_PROMPT and DIGEST_TEMPLATE', () => {
130203
test('DIGEST_PROMPT contains required four-section structure', async () => {
131204
const { DIGEST_PROMPT } = await import(

src/commands/push/push.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ function renderStackList(stack: readonly PushMarker[]): string {
1818
style: 'short',
1919
now,
2020
})
21-
// branchPreview (what the branch is about) wins over anchorPreview
22-
// (where it forked from); both are truncated original conversation text.
21+
// branchPreview (first message inside the branch) wins over anchorPreview
22+
// (the user's instruction at push time); both are truncated conversation text.
2323
const preview = m.branchPreview || m.anchorPreview || ''
2424
const note = m.note ? ` · ${m.note}` : ''
2525
const previewPart = preview ? ` · ${preview}` : ''

src/screens/REPL.tsx

Lines changed: 83 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ import {
290290
type CompactStrategyChoice,
291291
MAX_PUSH_DEPTH,
292292
setPushStackMirror,
293+
projectPushStackOntoMessages,
293294
} from '../services/pushStack/state.js';
294295
import { DIGEST_PROMPT, DIGEST_TEMPLATE } from '../services/pushStack/digestPrompt.js';
295296
import { CompactStrategyDialog } from '../components/pushStack/CompactStrategyDialog.js';
@@ -317,6 +318,7 @@ import {
317318
isEphemeralToolProgress,
318319
isLoggableMessage,
319320
saveWorktreeState,
321+
savePushStack,
320322
getAgentTranscript,
321323
} from '../utils/sessionStorage.js';
322324
import { deserializeMessages } from '../utils/conversationRecovery.js';
@@ -1770,6 +1772,10 @@ export function REPL({
17701772
// streaming text_delta. The spinner reads this via its animation timer.
17711773
const responseLengthRef = useRef(0);
17721774
const compactProgressActiveRef = useRef(false);
1775+
// Pop-specific spinner label (e.g. "Distilling discussion branch #1"). Set by
1776+
// applyPop before its fire-and-forget digest so the compact_start handler can
1777+
// show a pop-aware verb instead of the generic "Compacting conversation".
1778+
const popSpinnerLabelRef = useRef<string | null>(null);
17731779
// API performance metrics ref for ant-only spinner display (TTFT/OTPS).
17741780
// Accumulates metrics from all API requests in a turn for P50 aggregation.
17751781
const apiMetricsRef = useRef<
@@ -1825,6 +1831,11 @@ export function REPL({
18251831

18261832
const [lastQueryCompletionTime, setLastQueryCompletionTime] = useState(0);
18271833
const [spinnerMessage, setSpinnerMessage] = useState<string | null>(null);
1834+
// State mirror of compactProgressActiveRef — needed so showSpinner (a
1835+
// render-time derived value) turns the spinner on during a background compact
1836+
// that runs outside the query loop (e.g. /pop's fire-and-forget digest, where
1837+
// isLoading is already false). The ref alone can't trigger a re-render.
1838+
const [compactProgressActive, setCompactProgressActive] = useState(false);
18281839
const [spinnerColor, setSpinnerColor] = useState<keyof Theme | null>(null);
18291840
const [spinnerShimmerColor, setSpinnerShimmerColor] = useState<keyof Theme | null>(null);
18301841
const [isMessageSelectorVisible, setIsMessageSelectorVisible] = useState(false);
@@ -2061,6 +2072,8 @@ export function REPL({
20612072
(isLoading ||
20622073
userInputOnProcessing ||
20632074
hasRunningTeammates ||
2075+
// Background compaction running outside the query loop (e.g. /pop digest).
2076+
compactProgressActive ||
20642077
// Keep spinner visible while task notifications are queued for processing.
20652078
// Without this, the spinner briefly disappears between consecutive notifications
20662079
// (e.g., multiple background agents completing in rapid succession) because
@@ -2344,6 +2357,28 @@ export function REPL({
23442357
// Use a callback to ensure we're not dependent on stale state
23452358
setMessages(() => messages);
23462359

2360+
// Hydrate the push/pop stack, re-projecting each marker onto the just-
2361+
// restored messages: a marker only survives if its anchor is still in
2362+
// the active context (not carried off by compaction/snip), the same
2363+
// check /pop performs — so a restored stack can never point at a message
2364+
// that no longer exists. Dropped markers are surfaced so a persisted
2365+
// stack is never silently lost. The setAppState below re-triggers the
2366+
// mirror effect, re-persisting the projected (pruned) stack last-wins.
2367+
// Skipped for /branch (fork) like worktreeSession: forkLog doesn't carry
2368+
// the stack.
2369+
if (feature('PUSH_POP') && entrypoint !== 'fork' && log.pushStack?.length) {
2370+
const { validMarkers, droppedCount } = projectPushStackOntoMessages(log.pushStack, messages);
2371+
setAppState(prev => ({ ...prev, pushStack: validMarkers }));
2372+
if (droppedCount > 0) {
2373+
addNotification({
2374+
key: 'push-stack-resume-dropped',
2375+
text: `${droppedCount} 个 push 点在恢复后已失效(被压缩/snip 卷走),已移除`,
2376+
priority: 'medium',
2377+
timeoutMs: 6000,
2378+
});
2379+
}
2380+
}
2381+
23472382
// Clear any active tool JSX
23482383
setToolJSX(null);
23492384

@@ -2363,7 +2398,7 @@ export function REPL({
23632398
throw error;
23642399
}
23652400
},
2366-
[resetLoadingState, setAppState],
2401+
[resetLoadingState, setAppState, addNotification],
23672402
);
23682403

23692404
// Lazy init: useRef(createX()) would call createX on every render and
@@ -2918,9 +2953,17 @@ export function REPL({
29182953
);
29192954

29202955
// Keep the module-level mirror in sync so autoCompactIfNeeded (outside React)
2921-
// can read the current push stack without needing AppState access.
2956+
// can read the current push stack without needing AppState access. This is
2957+
// also the single point that fans in every stack mutation (push/pop/retain),
2958+
// so it doubles as the persistence trigger: once the stack has been non-empty,
2959+
// each change (including popping back to empty) is written last-wins so the
2960+
// transcript always reflects the live stack. The dirty ref keeps sessions that
2961+
// never push from writing an empty entry.
2962+
const pushStackDirty = useRef(false);
29222963
useEffect(() => {
29232964
setPushStackMirror(pushStack);
2965+
if (pushStack.length > 0) pushStackDirty.current = true;
2966+
if (pushStackDirty.current) savePushStack(pushStack);
29242967
}, [pushStack]);
29252968

29262969
// Ref holding the stable push/pop callbacks so getToolUseContext can forward-
@@ -3071,14 +3114,24 @@ export function REPL({
30713114
);
30723115
break;
30733116
case 'compact_start':
3074-
setSpinnerMessage('Compacting conversation');
3117+
setSpinnerMessage(popSpinnerLabelRef.current ?? 'Compacting conversation');
30753118
compactProgressActiveRef.current = true;
3119+
setCompactProgressActive(true);
3120+
// /pop runs its digest outside the query loop, so the normal
3121+
// loading timer never started — seed it here (when idle) so the
3122+
// spinner's elapsed-time counter starts from 0 rather than a
3123+
// stale value from a previous turn.
3124+
if (!isLoading) {
3125+
loadingStartTimeRef.current = Date.now();
3126+
totalPausedMsRef.current = 0;
3127+
}
30763128
break;
30773129
case 'compact_end':
30783130
setSpinnerMessage(null);
30793131
setSpinnerColor(null);
30803132
setSpinnerShimmerColor(null);
30813133
compactProgressActiveRef.current = false;
3134+
setCompactProgressActive(false);
30823135
break;
30833136
}
30843137
},
@@ -3247,13 +3300,17 @@ export function REPL({
32473300
]);
32483301
return;
32493302
}
3250-
const lastContent =
3251-
(last as { content?: unknown; message?: { content?: unknown } }).content ??
3252-
(last as { content?: unknown; message?: { content?: unknown } }).message?.content ??
3253-
'';
3254-
const anchorText =
3255-
getContentText(lastContent as string | import('@anthropic-ai/sdk/resources/index.mjs').ContentBlockParam[]) ??
3303+
// Preview the user's most recent instruction (what this branch is about)
3304+
// rather than the raw last message — which is often a tool-only assistant
3305+
// turn (empty text) or a synthetic "No response requested." placeholder,
3306+
// neither of which is meaningful in `/push --list` (§4.7).
3307+
const instruction = msgs.findLast(selectableUserMessagesFilter);
3308+
const previewSource = instruction ?? last;
3309+
const previewContent =
3310+
(previewSource as { content?: unknown; message?: { content?: unknown } }).content ??
3311+
(previewSource as { content?: unknown; message?: { content?: unknown } }).message?.content ??
32563312
'';
3313+
const anchorText = getContentText(previewContent as string | ContentBlockParam[]) ?? '';
32573314
const anchorPreview = anchorText.slice(0, 75).trimEnd();
32583315
const marker: PushMarker = {
32593316
id: randomUUID(),
@@ -3354,8 +3411,13 @@ export function REPL({
33543411
// the last main-line message that must stay in the kept prefix (§3
33553412
// off-by-one). The fast-path above guarantees pivotIndex is in range.
33563413
const firstDiscussionUuid = compactMessages[pivotIndex]!.uuid;
3414+
let compactResult: import('../services/compact/compact.js').CompactionResult | undefined;
3415+
// Pop-aware spinner label — the compact_start handler reads this so the
3416+
// digest shows "Distilling discussion branch #N" (with live token count
3417+
// + elapsed time) instead of the generic "Compacting conversation".
3418+
popSpinnerLabelRef.current = `\u2442 Distilling discussion branch #${targetIdx + 1}`;
33573419
try {
3358-
await applyPartialCompactByUuid({
3420+
compactResult = await applyPartialCompactByUuid({
33593421
pivotUuid: firstDiscussionUuid,
33603422
feedback: DIGEST_TEMPLATE,
33613423
direction: 'from',
@@ -3371,6 +3433,8 @@ export function REPL({
33713433
),
33723434
]);
33733435
return;
3436+
} finally {
3437+
popSpinnerLabelRef.current = null;
33743438
}
33753439

33763440
// Pop the stack: plain pop removes top; --to #N removes #N and above.
@@ -3391,9 +3455,17 @@ export function REPL({
33913455
}
33923456

33933457
const historyShortcut = getShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o');
3458+
// Before → after context size (0 extra API tokens: pre is already
3459+
// computed during compaction, post is a local message-payload estimate).
3460+
const pre = compactResult?.preCompactTokenCount;
3461+
const post = compactResult?.truePostCompactTokenCount;
3462+
const tokenLine =
3463+
pre !== undefined && post !== undefined
3464+
? ` · Context: ~${formatTokens(pre)} \u2192 ~${formatTokens(post)} tokens`
3465+
: '';
33943466
addNotification({
33953467
key: `pop-digest-${marker.id}`,
3396-
text: `\u2442 Discussion branch #${targetIdx + 1} distilled (${historyShortcut} for history)`,
3468+
text: `\u2442 Discussion branch #${targetIdx + 1} distilled (${historyShortcut} for history)${tokenLine}`,
33973469
priority: 'medium',
33983470
timeoutMs: 8000,
33993471
});

src/services/compact/compact.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,6 +1135,16 @@ export async function partialCompactConversation(
11351135
direction === 'up_to'
11361136
? (summaryMessages.at(-1)?.uuid ?? boundaryMarker.uuid)
11371137
: boundaryMarker.uuid
1138+
// Message-payload estimate of the resulting context (local, 0 extra API
1139+
// tokens) — mirrors the full-compact path (see truePostCompactTokenCount
1140+
// above). Order-independent, so keep/summary interleaving doesn't matter.
1141+
const truePostCompactTokenCount = roughTokenCountEstimationForMessages([
1142+
boundaryMarker,
1143+
...messagesToKeep,
1144+
...summaryMessages,
1145+
...postCompactFileAttachments,
1146+
...hookMessages,
1147+
] as Parameters<typeof roughTokenCountEstimationForMessages>[0])
11381148
return {
11391149
boundaryMarker: annotateBoundaryWithPreservedSegment(
11401150
boundaryMarker,
@@ -1148,6 +1158,7 @@ export async function partialCompactConversation(
11481158
userDisplayMessage: postCompactHookResult.userDisplayMessage,
11491159
preCompactTokenCount,
11501160
postCompactTokenCount,
1161+
truePostCompactTokenCount,
11511162
compactionUsage,
11521163
}
11531164
} catch (error) {

src/services/pushStack/state.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type { UUID } from 'crypto'
2+
import type { Message } from '../../types/message.js'
3+
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'
24

35
/**
46
* Push/Pop context stack (docs/features/push-pop-context-stack.md).
@@ -15,7 +17,7 @@ export type PushMarker = {
1517
note: string
1618
/** Epoch ms at push time (for relative-time display in `/push --list`). */
1719
timestamp: number
18-
/** Snapshot of the last message text at push time (where the branch forked from). */
20+
/** Preview of the user's most recent instruction at push time (what the branch is about). */
1921
anchorPreview: string
2022
/** First user message after push (what the branch is about); backfilled lazily. */
2123
branchPreview?: string
@@ -65,3 +67,25 @@ export function getPushStackMirror(): readonly PushMarker[] {
6567
export function setPushStackMirror(stack: readonly PushMarker[]): void {
6668
pushStackMirror = [...stack]
6769
}
70+
71+
/**
72+
* Projects a persisted push stack back onto the live message sequence at resume
73+
* time. A marker only survives if its anchor message (`messageUuid`) is still in
74+
* the active context — i.e. not carried off by compaction or history-snip. This
75+
* mirrors the validation `/pop` performs (REPL) so a restored marker can never
76+
* point at a message that no longer exists. Dropped markers are counted so the
77+
* caller can transparently notify the user (never silently lose a stack marker).
78+
*/
79+
export function projectPushStackOntoMessages(
80+
persisted: readonly PushMarker[],
81+
messages: Message[],
82+
): { validMarkers: PushMarker[]; droppedCount: number } {
83+
const activeUuids = new Set(
84+
getMessagesAfterCompactBoundary(messages).map(m => m.uuid),
85+
)
86+
const validMarkers = persisted.filter(m => activeUuids.has(m.messageUuid))
87+
return {
88+
validMarkers,
89+
droppedCount: persisted.length - validMarkers.length,
90+
}
91+
}

0 commit comments

Comments
 (0)