feat(web): copy agent resume commands#1060
Conversation
There was a problem hiding this comment.
Findings
- [Major] Gemini resume commands are offered even though the CLI deliberately rejects Gemini resume.
web/src/lib/agentSessionId.ts:43buildsgemini --resume <id>, but the command registry keepsgeminias a tombstone andhapi resumethrows before handoff for Gemini sessions. This makes the new action appear for sessions where the copied command cannot work.
Suggested fix:
const RESUME_COMMAND_BY_FLAVOR = {
claude: (id: string) => claude --resume ${quoteResumeId(id)},
codex: (id: string) => codex resume ${quoteResumeId(id)},
opencode: (id: string) => opencode -s ${quoteResumeId(id)},
grok: (id: string) => grok --resume ${quoteResumeId(id)},
cursor: (id: string) => agent resume ${quoteResumeId(id)},
kimi: (id: string) => kimi --resume ${quoteResumeId(id)},
pi: (id: string) => pi --session-id ${quoteResumeId(id)}
} as const satisfies Partial<Record<keyof typeof SESSION_ID_FIELD_BY_FLAVOR, (id: string) => string>>
- [Major] Native session IDs are interpolated into shell commands without quoting or validation. Metadata schemas accept arbitrary strings for these ids, so an imported or agent-supplied id containing shell metacharacters would be copied as executable syntax rather than a single argument. Evidence: `web/src/lib/agentSessionId.ts:73`.
Suggested fix:
```ts
function quoteResumeId(id: string): string {
return `'${id.replaceAll("'", "'\\''")}'`
}
return RESUME_COMMAND_BY_FLAVOR[normalizedFlavor as keyof typeof RESUME_COMMAND_BY_FLAVOR](normalizedSessionId)
- [Major] The session-list menu can copy the wrong native id for the current flavor. The new row action trusts the flattened
agentSessionId(web/src/components/SessionList.tsx:655), buttoSessionSummarycurrently picks ids by fixed field order (codexSessionIdbeforecursorSessionId/kimiSessionId) and does not includepiSessionId. A session withflavor: 'cursor'plus stalecodexSessionIdwill copyagent resume <codex-id>, while Pi rows never get the advertised action.
Suggested fix:
function getSummaryAgentSessionId(metadata: Metadata): string | undefined {
const flavor = metadata.flavor?.trim().toLowerCase()
if (flavor === 'codex') return metadata.codexSessionId
if (flavor === 'claude') return metadata.claudeSessionId
if (flavor === 'gemini') return metadata.geminiSessionId
if (flavor === 'opencode') return metadata.opencodeSessionId
if (flavor === 'grok') return metadata.grokSessionId
if (flavor === 'cursor') return metadata.cursorSessionId
if (flavor === 'kimi') return metadata.kimiSessionId
if (flavor === 'pi') return metadata.piSessionId
return metadata.codexSessionId
?? metadata.claudeSessionId
?? metadata.geminiSessionId
?? metadata.opencodeSessionId
?? metadata.grokSessionId
?? metadata.cursorSessionId
?? metadata.kimiSessionId
?? metadata.piSessionId
?? undefined
}
**Summary**
- Review mode: initial
- The main risks are incorrect/unusable copied commands and unsafe command construction. The new focused tests cover happy paths, but they miss Gemini unsupported behavior, shell metacharacters in ids, stale mixed-id summaries, and Pi session-list coverage.
**Testing**
- Not run: `bun` is not installed in this runner (`bun: command not found`).
*HAPI Bot*
| const RESUME_COMMAND_BY_FLAVOR = { | ||
| claude: (id: string) => `claude --resume ${id}`, | ||
| codex: (id: string) => `codex resume ${id}`, | ||
| gemini: (id: string) => `gemini --resume ${id}`, |
There was a problem hiding this comment.
[MAJOR] This exposes a copy action for Gemini sessions even though this repo explicitly tombstones Gemini local launch/resume: cli/src/commands/registry.ts:24 registers gemini as unsupported and cli/src/commands/resume.ts:221 rejects Gemini before handoff. A copied gemini --resume <id> will fail for exactly the sessions where the action appears.
Suggested fix:
const RESUME_COMMAND_BY_FLAVOR = {
claude: (id: string) => `claude --resume ${quoteResumeId(id)}`,
codex: (id: string) => `codex resume ${quoteResumeId(id)}`,
opencode: (id: string) => `opencode -s ${quoteResumeId(id)}`,
grok: (id: string) => `grok --resume ${quoteResumeId(id)}`,
cursor: (id: string) => `agent resume ${quoteResumeId(id)}`,
kimi: (id: string) => `kimi --resume ${quoteResumeId(id)}`,
pi: (id: string) => `pi --session-id ${quoteResumeId(id)}`
} as const satisfies Partial<Record<keyof typeof SESSION_ID_FIELD_BY_FLAVOR, (id: string) => string>>Also change the Gemini command-generation test to expect null.
| return null | ||
| } | ||
|
|
||
| return RESUME_COMMAND_BY_FLAVOR[normalizedFlavor as keyof typeof RESUME_COMMAND_BY_FLAVOR](normalizedSessionId) |
There was a problem hiding this comment.
[MAJOR] The session id is copied into a shell command as raw text. Since the metadata schema only requires these fields to be strings, an imported/agent-supplied id with shell metacharacters is copied as extra shell syntax instead of one argv value.
Suggested fix:
function quoteResumeId(id: string): string {
return `'${id.replaceAll("'", "'\\''")}'`
}
return RESUME_COMMAND_BY_FLAVOR[normalizedFlavor as keyof typeof RESUME_COMMAND_BY_FLAVOR](normalizedSessionId)Then make each command builder use quoteResumeId(id) and add a regression test with an id containing spaces/quotes/semicolons.
| }) | ||
|
|
||
| const sessionName = getSessionTitle(s) | ||
| const resumeCommand = getResumeCommand(s.metadata?.flavor, s.metadata?.agentSessionId) |
There was a problem hiding this comment.
[MAJOR] This row action trusts the flattened summary agentSessionId for the current flavor, but summaries are not flavor-aware today: shared/src/sessionSummary.ts:118 picks codexSessionId before later flavor ids and omits piSessionId. That means a Cursor/Kimi/Pi row with stale/missing flattened data can copy the wrong command or hide the advertised Pi action, while the header path uses full metadata and gets the matching field.
Suggested fix:
function getSummaryAgentSessionId(metadata: Metadata): string | undefined {
const flavor = metadata.flavor?.trim().toLowerCase()
if (flavor === 'codex') return metadata.codexSessionId
if (flavor === 'claude') return metadata.claudeSessionId
if (flavor === 'gemini') return metadata.geminiSessionId
if (flavor === 'opencode') return metadata.opencodeSessionId
if (flavor === 'grok') return metadata.grokSessionId
if (flavor === 'cursor') return metadata.cursorSessionId
if (flavor === 'kimi') return metadata.kimiSessionId
if (flavor === 'pi') return metadata.piSessionId
return metadata.codexSessionId
?? metadata.claudeSessionId
?? metadata.geminiSessionId
?? metadata.opencodeSessionId
?? metadata.grokSessionId
?? metadata.cursorSessionId
?? metadata.kimiSessionId
?? metadata.piSessionId
?? undefined
}Use that in toSessionSummary and add tests for stale mixed ids plus Pi.
093a37e to
28c93f9
Compare
|
Addressed all three review findings in 28c93f9:
Added focused tests for Gemini, unsafe IDs, stale Cursor/Codex metadata, and Pi summaries. |
There was a problem hiding this comment.
Findings
- [Major] Kimi resume command uses the wrong native flag. The new command table copies
kimi --resume <id>, but the repo's Kimi launcher resumes native Kimi sessions with--session(cli/src/kimi/kimiLocal.ts:15), and--resumeis not found in the repo docs/source for Kimi. This makes the new menu action copy an unusable command for Kimi sessions. Evidence:web/src/lib/agentSessionId.ts:46.
Suggested fix:const RESUME_COMMAND_BY_FLAVOR = { claude: (id: string) => `claude --resume ${id}`, codex: (id: string) => `codex resume ${id}`, opencode: (id: string) => `opencode -s ${id}`, grok: (id: string) => `grok --resume ${id}`, cursor: (id: string) => `agent resume ${id}`, kimi: (id: string) => `kimi --session ${id}`, pi: (id: string) => `pi --session-id ${id}` } as const satisfies Partial<Record<keyof typeof SESSION_ID_FIELD_BY_FLAVOR, (id: string) => string>>
Questions
- None.
Summary
- Review mode: follow-up after new commits
- The previous bot findings around Gemini, unsafe IDs, and stale summary IDs appear addressed. Remaining issue: Kimi copies a command that does not match the repo's own native Kimi resume flag.
Testing
- Not run (review only; did not execute PR code). Existing new test should update the Kimi expectation to
kimi --session kimi-id.
HAPI Bot
| opencode: (id: string) => `opencode -s ${id}`, | ||
| grok: (id: string) => `grok --resume ${id}`, | ||
| cursor: (id: string) => `agent resume ${id}`, | ||
| kimi: (id: string) => `kimi --resume ${id}`, |
There was a problem hiding this comment.
[MAJOR] This copies kimi --resume <id>, but the repo's native Kimi launcher resumes sessions with --session (cli/src/kimi/kimiLocal.ts:15), and --resume is not found in the repo docs/source for Kimi. The new action will therefore copy an unusable command for Kimi sessions.
Suggested fix:
kimi: (id: string) => `kimi --session ${id}`,Also update the Kimi expectation in web/src/lib/agentSessionId.test.ts to kimi --session kimi-id.
28c93f9 to
bef718b
Compare
|
Addressed the Kimi follow-up in bef718b: the copied command now uses |
There was a problem hiding this comment.
Findings
- [Major] Session-list copy can use a stale cross-agent ID —
getSummaryAgentSessionIdstill falls back to another agent's stored ID after a known flavor's matching field is missing. The session list then has onlyflavor + agentSessionId, so api/cursor/etc. session with only a stalecodexSessionIdwill copy a command likepi --session-id stale-codexoragent resume stale-codex. This conflicts with the existing resume path, which ignores cross-flavor IDs (web/src/lib/sessionResume.ts:4,hub/src/sync/syncEngine.ts:835). Evidence:shared/src/sessionSummary.ts:131, consumed atweb/src/components/SessionList.tsx:655.
Suggested fix:if (flavorField) { const flavorSessionId = metadata[flavorField] return typeof flavorSessionId === 'string' && flavorSessionId.trim() ? flavorSessionId.trim() : undefined } // Only use legacy fallback when flavor is unknown or missing.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- Previous Kimi command issue is resolved. Remaining issue: the session-list copy path can still generate a wrong native command when summary metadata flattens a stale ID from a different flavor.
Testing
- Not run (review only; did not execute PR code). Add coverage for a known-flavor summary with only a stale cross-flavor ID and assert the copy action is hidden.
HAPI Bot
| } | ||
| } | ||
|
|
||
| return metadata.codexSessionId |
There was a problem hiding this comment.
[MAJOR] This fallback still returns an ID from another agent after the current known flavor's matching field is missing. The session-list action later receives only flavor + agentSessionId, so a pi/cursor/etc. row with a stale codexSessionId can copy a command for the current flavor using the wrong native ID. Existing resume handling explicitly ignores cross-flavor IDs (web/src/lib/sessionResume.ts:4, hub/src/sync/syncEngine.ts:835), so the flattened summary should do the same for known flavors.
Suggested fix:
if (flavorField) {
const flavorSessionId = metadata[flavorField]
return typeof flavorSessionId === 'string' && flavorSessionId.trim()
? flavorSessionId.trim()
: undefined
}
// Only fall back when the flavor is unknown or missing.Also add a test where flavor: 'pi' or flavor: 'cursor' has only codexSessionId; agentSessionId should be absent and the row menu should hide the copy action.
bef718b to
db0774c
Compare
|
Addressed the stale cross-agent fallback in db0774c. Known flavors now return only their matching native session ID; if that field is missing, the summary leaves |
There was a problem hiding this comment.
Findings
- [Minor] Session-list copy failures are silent — the new row-menu handler catches and drops
safeCopyToClipboarderrors, so when clipboard access is unavailable the menu closes and the user gets no indication that nothing was copied. The header copy path already shows success/error toasts, but the list path suppresses the same failure atweb/src/components/SessionList.tsx:755.
Suggested fix:const toast = useToast() const handleCopyResumeCommand = async () => { if (!resumeCommand) return try { await safeCopyToClipboard(resumeCommand) toast.addToast({ title: t('session.copyResumeCommand.toast.success.title'), body: t('session.copyResumeCommand.toast.success.body'), sessionId: s.id, url: `/sessions/${s.id}` }) } catch { toast.addToast({ title: t('session.copyResumeCommand.toast.error.title'), body: t('session.copyResumeCommand.toast.error.body'), sessionId: s.id, url: `/sessions/${s.id}` }) } }
Summary
- Review mode: follow-up after new commits
- Prior stale cross-agent summary issue is addressed. Remaining issue: the session-list entry point can fail its only operation without feedback.
Testing
- Not run (automation; did not execute PR code). Add a row-menu test where
navigator.clipboard.writeTextrejects and assert an error toast is shown.
HAPI Bot
| sessionActive={s.active} | ||
| onRename={() => setRenameOpen(true)} | ||
| onCopyResumeCommand={resumeCommand | ||
| ? () => { void safeCopyToClipboard(resumeCommand).catch(() => {}) } |
There was a problem hiding this comment.
[MINOR] This row-menu copy handler drops clipboard failures, so if safeCopyToClipboard throws the menu closes and the user gets no indication that nothing was copied. The header entry point already reports success/error toasts; the list entry point should do the same.
Suggested fix:
const toast = useToast()
const handleCopyResumeCommand = async () => {
if (!resumeCommand) return
try {
await safeCopyToClipboard(resumeCommand)
toast.addToast({
title: t('session.copyResumeCommand.toast.success.title'),
body: t('session.copyResumeCommand.toast.success.body'),
sessionId: s.id,
url: `/sessions/${s.id}`
})
} catch {
toast.addToast({
title: t('session.copyResumeCommand.toast.error.title'),
body: t('session.copyResumeCommand.toast.error.body'),
sessionId: s.id,
url: `/sessions/${s.id}`
})
}
}db0774c to
f7b9afb
Compare
|
Addressed the silent session-list clipboard failure in f7b9afb. The row action now mirrors the header path with success and error toasts. Added coverage for both successful copy feedback and a rejected Clipboard API plus failed fallback. |
There was a problem hiding this comment.
Findings
None.
Summary
- Review mode: follow-up after new commits
- No issues found in the latest diff. The prior clipboard-failure feedback issue is addressed; remaining risk is limited to tests not being run in this review pass.
Testing
- Not run (automation; did not execute PR code).
HAPI Bot
|
We already support |
@tiann 感谢您的回复和说明。我认同 Agent 的恢复逻辑应该统一放在 不过,我的真实需求并不是单纯恢复旧会话,而是“跨会话引用”:
目前我使用类似下面的内容来告诉 Agent 如何定位旧会话: 目的主要是读取历史,而不是接管或交互式恢复会话 B。 想确认一下,您希望这里改为复制: 还是希望扩展 HAPI,提供类似 如果现有的 Thank you for the clarification. I agree that agent resume logic should be centralized in However, my actual use case is not simply resuming an old session. It is cross-session referencing:
At the moment, I give the agent something like: The main goal is to locate and read the previous transcript, rather than hand off to or interactively resume session B. Would you prefer this action to copy: Or would it be better to extend HAPI with a non-interactive, read-only command such as If the existing |
|
如果你的需求是告诉agent 如何读取历史,最直接的方式是直接给 path,因为 resume 并不是给 agent 用的,它也并不会执行什么东西,对它有用的就是那个 sessionid。但是可能有个问题,有的 agent 用的 sqlite? |
@tiann 感谢说明,我理解您的意思了:会话历史应该由 HAPI 统一读取和转换,而不是让 Agent 根据原生 Session ID 自己寻找 Claude、Codex 或 SQLite 等不同存储位置。 重新考虑后,我也觉得下面这个新方案比当前 #1060 的“复制 Agent 恢复命令”更符合实际需求。如果这个方向可行,我认为 #1060 就没有继续保留的必要,可以关闭并改为单独讨论或实现新方案。 我的实际场景是在会话 A 中引用会话 B,让当前 Agent 读取会话 B 的历史后继续当前任务。相比手动执行导出命令,我更希望在 HAPI Web 中直接完成:
大致交互是: 底层是否可以复用现有的 Session Export/Message Service,再提供一个类似 如果 MCP 工具不是合适的实现方式,也可以由 Hub 在发送消息时解析结构化会话引用并提供历史内容。我的核心需求是:
您觉得这种“交互式引用会话”的方向是否符合 HAPI 的设计?如果认可,我可以关闭 #1060,再根据您建议的底层方式整理一个独立 Issue 或更小范围的 PR。谢谢! |
Summary
Add a Copy resume command action to both session action menus: the active session header and the session-list context menu.
The copied text is a directly runnable, agent-native command rather than a HAPI URL or bare session ID, for example:
Motivation
HAPI already stores each coding agent's native conversation ID, but retrieving it currently requires exporting the conversation as JSON and locating an agent-specific metadata field such as
codexSessionIdorclaudeSessionId.Copying a ready-to-run command makes it easier to continue the same conversation directly in the corresponding local CLI while keeping the agent identity unambiguous.
Changes
agentSessionIdfrom session summaries in the session list.claude --resume <id>codex resume <id>opencode -s <id>grok --resume <id>agent resume <id>kimi --session <id>pi --session-id <id>This is intentionally separate from a broader effort to make every action in the header and session-list menus identical. Unlike #951, this action copies an agent-native resume command rather than a cross-session HAPI reference.
Testing
bun typecheckbun run test:webbun test shared/src/sessionSummary.test.tsbun run --cwd web test src/lib/agentSessionId.test.ts src/components/SessionList.directory-action.test.tsx src/components/SessionActionMenu.test.tsxgit diff --checkAI disclosure
Implementation and tests were produced with OpenAI Codex (GPT-5.6).