Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Fork a session in the native window.** Right-click a card and pick
Fork › Here to carry its conversation on in a new card right under it, in
the same checkout, or Fork › Worktree… to take it to a new worktree along
with your uncommitted changes. The copy is named after the original
("auth-refactor 2") and wears a fork mark. Claude Code, Codex and opencode
only: the other providers only resume.
- **The native window warns when two cards run one conversation.** Resuming,
from inside an agent's own TUI, a conversation another card already has
open raises a warning in the bottom-right corner; Switch closes the card
Expand Down
2 changes: 1 addition & 1 deletion README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ porque lee lo que cada CLI deja escrito y no hay dos que anoten las mismas cosas
| Buscar en la conversación desde la paleta | sí | sí | sí | sí | sí | sí | no | sí |
| Reanudar una conversación iniciada fuera de lich, desde la pestaña Historial de la paleta | sí | sí | sí | sí | sí | Linux; macOS y Windows sin probar | sí | sí |
| Un pegado largo puede llegar como texto editable en vez de un marcador plegado (Ajustes › Proveedores, desactivado por defecto) | sí | no | no | sí | no medido | no, se convierte en un adjunto `paste_N.txt` | no | sí |
| Bifurcar una conversación en un nuevo worktree | sí | sí | no | sí | no | no | no | no |
| Bifurcar una conversación en un nuevo worktree, o en su propio checkout en la ventana nativa | sí | sí | no | sí | no | no | no | no |
| Aviso en la ventana nativa cuando retomas, desde la propia app del agente, una conversación que otra tarjeta tiene abierta | sí | en tu próximo prompt | en tu próximo prompt, sin probar | no | no | en su próxima llamada a una tool | no | sí |
| Abrir una sesión con un esfuerzo de razonamiento elegido | sí | sí | sí | no | sí | no | en el nombre del modelo | sí |
| Abrir una sesión con ultracode activado | sí | no | no | no | no | no | no | no |
Expand Down
2 changes: 1 addition & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ lich は各 CLI が書き残したものを読むので、書き残す内容は
| パレットから会話を検索 | あり | あり | あり | あり | あり | あり | なし | あり |
| lich の外で始めた会話を、パレットの History タブから再開 | あり | あり | あり | あり | あり | Linux のみ。macOS と Windows は未検証 | あり | あり |
| 長いペーストを、折りたたまれたプレースホルダーではなく編集可能なテキストとして貼り付けられる(Settings › Providers、既定ではオフ) | あり | なし | なし | あり | 未計測 | なし(`paste_N.txt` の添付になる) | なし | あり |
| 会話を新しいワークツリーにフォーク | あり | あり | なし | あり | なし | なし | なし | なし |
| 会話を新しいワークツリーにフォーク(ネイティブウィンドウでは同じチェックアウト内にも) | あり | あり | なし | あり | なし | なし | なし | なし |
| 別のカードで開いている会話をエージェント自身のアプリで再開すると、ネイティブウィンドウに警告 | あり | 次のプロンプトで | 次のプロンプトで(未検証) | なし | なし | 次のツール呼び出しで | なし | あり |
| 推論エフォートを指定してセッションを開く | あり | あり | あり | なし | あり | なし | モデル名で指定 | あり |
| ultracode をオンにしてセッションを開く | あり | なし | なし | なし | なし | なし | なし | なし |
Expand Down
2 changes: 1 addition & 1 deletion README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ lich로 할 수 있는 일:
| 팔레트에서 대화 검색 | 예 | 예 | 예 | 예 | 예 | 예 | 아니요 | 예 |
| lich 밖에서 시작한 대화를 팔레트의 History 탭에서 재개 | 예 | 예 | 예 | 예 | 예 | Linux; macOS와 Windows는 미검증 | 예 | 예 |
| 긴 붙여 넣기를 접힌 자리 표시자 대신 편집 가능한 텍스트로 넣기(Settings › Providers, 기본값 꺼짐) | 예 | 아니요 | 아니요 | 예 | 측정 안 됨 | 아니요, `paste_N.txt` 첨부 파일이 됨 | 아니요 | 예 |
| 대화를 새 worktree로 포크 | 예 | 예 | 아니요 | 예 | 아니요 | 아니요 | 아니요 | 아니요 |
| 대화를 새 worktree로 포크, 네이티브 창에서는 같은 체크아웃에도 포크 | 예 | 예 | 아니요 | 예 | 아니요 | 아니요 | 아니요 | 아니요 |
| 다른 카드에 열려 있는 대화를 에이전트 자체 앱에서 재개하면 네이티브 창에 경고 | 예 | 다음 프롬프트에서 | 다음 프롬프트에서, 미검증 | 아니요 | 아니요 | 다음 도구 호출에서 | 아니요 | 예 |
| 원하는 reasoning effort로 세션 열기 | 예 | 예 | 예 | 아니요 | 예 | 아니요 | 모델 이름으로 | 예 |
| ultracode를 켠 채로 세션 열기 | 예 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ reads what each CLI writes down and no two of them write down the same things.
| Search the conversation from the palette | yes | yes | yes | yes | yes | yes | no | yes |
| Resume a conversation started outside lich, from the palette's History tab | yes | yes | yes | yes | yes | Linux; macOS and Windows untested | yes | yes |
| A long paste can land as editable text instead of a folded placeholder (Settings › Providers, off by default) | yes | no | no | yes | not measured | no, it becomes a `paste_N.txt` attachment | no | yes |
| Fork a conversation into a new worktree | yes | yes | no | yes | no | no | no | no |
| Fork a conversation into a new worktree, or in its own checkout in the native window | yes | yes | no | yes | no | no | no | no |
| Warned in the native window when you resume, from the agent's own app, a conversation another card has open | yes | on your next prompt | on your next prompt, untested | no | no | at its next tool call | no | yes |
| Open a session at a chosen reasoning effort | yes | yes | yes | no | yes | no | in the model name | yes |
| Open a session with ultracode on | yes | no | no | no | no | no | no | no |
Expand Down
2 changes: 1 addition & 1 deletion README.pt-BR.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ mesmas coisas.
| Pesquisar a conversa pela paleta | sim | sim | sim | sim | sim | sim | não | sim |
| Retomar pela aba Histórico da paleta uma conversa iniciada fora do lich | sim | sim | sim | sim | sim | Linux; macOS e Windows não testados | sim | sim |
| Um paste longo pode chegar como texto editável em vez de um marcador dobrado (Configurações › Provedores, desligado por padrão) | sim | não | não | sim | não medido | não, vira um anexo `paste_N.txt` | não | sim |
| Fazer fork de uma conversa para uma nova worktree | sim | sim | não | sim | não | não | não | não |
| Fazer fork de uma conversa para uma nova worktree, ou no mesmo checkout na janela nativa | sim | sim | não | sim | não | não | não | não |
| Aviso na janela nativa quando você retoma, pelo próprio app do agente, uma conversa que outro card tem aberta | sim | no seu próximo prompt | no seu próximo prompt, não testado | não | não | na próxima chamada de tool | não | sim |
| Abrir uma sessão com um esforço de raciocínio escolhido | sim | sim | sim | não | sim | não | no nome do modelo | sim |
| Abrir uma sessão com o ultracode ligado | sim | não | não | não | não | não | não | não |
Expand Down
2 changes: 1 addition & 1 deletion README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ lich вообще не запускается.
| Поиск по разговору из палитры | да | да | да | да | да | да | нет | да |
| Возобновление разговора, начатого вне lich, со вкладки «История» палитры | да | да | да | да | да | Linux; macOS и Windows не проверялись | да | да |
| Длинная вставка может попасть как редактируемый текст вместо свёрнутой заглушки (Настройки › Провайдеры, по умолчанию выключено) | да | нет | нет | да | не измерялось | нет, она становится вложением `paste_N.txt` | нет | да |
| Форк разговора в новый worktree | да | да | нет | да | нет | нет | нет | нет |
| Форк разговора в новый worktree, а в нативном окне и в том же checkout | да | да | нет | да | нет | нет | нет | нет |
| Предупреждение в нативном окне, когда вы возобновляете в самом приложении агента разговор, открытый в другой карточке | да | при следующем промпте | при следующем промпте, не проверено | нет | нет | при следующем вызове инструмента | нет | да |
| Открытие сессии с выбранным уровнем рассуждений | да | да | да | нет | да | нет | в названии модели | да |
| Открытие сессии с включённым ultracode | да | нет | нет | нет | нет | нет | нет | нет |
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@
| 从命令面板搜索对话里说过的内容 | 有 | 有 | 有 | 有 | 有 | 有 | 无 | 有 |
| 在命令面板的 History 页恢复在 lich 之外开始的对话 | 有 | 有 | 有 | 有 | 有 | Linux;macOS 和 Windows 未测 | 有 | 有 |
| 长文本粘贴可以以可编辑的完整文本出现,而不是折叠成占位符(设置 › Providers,默认关闭) | 有 | 无 | 无 | 有 | 未测 | 无,会变成 `paste_N.txt` 附件 | 无 | 有 |
| 把一段对话分叉到新的 worktree | 有 | 有 | 无 | 有 | 无 | 无 | 无 | 无 |
| 把一段对话分叉到新的 worktree,在原生窗口中也可在同一检出内分叉 | 有 | 有 | 无 | 有 | 无 | 无 | 无 | 无 |
| 在智能体自己的应用里恢复另一张卡片已打开的对话时,原生窗口发出警告 | 有 | 在你的下一个提示时 | 在你的下一个提示时,未测试 | 无 | 无 | 在它的下一次工具调用时 | 无 | 有 |
| 按指定的推理强度打开会话 | 有 | 有 | 有 | 无 | 有 | 无 | 写在模型名里 | 有 |
| 以 ultracode 模式打开会话 | 有 | 无 | 无 | 无 | 无 | 无 | 无 | 无 |
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/lib/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,9 @@ export interface StoredSession {
originSessionId: string
/** What that session was called at the time; all that survives its close. */
originLabel: string
/** Whether the session was born branching its origin's conversation, rather
* than delegated or opened as a subagent. Only the native window reads it. */
forked?: boolean
/** When the prompt below is due, in unix seconds; 0 for a session with
* nothing waiting. One slot per session — scheduling again replaces it. */
scheduledAt: number
Expand Down
9 changes: 9 additions & 0 deletions internal/store/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ var migrations = []func(*sql.Tx) error{
addSessionColor,
addProviderSessionTombstones,
addSessionProfile,
addSessionForked,
}

// addSessionEffort is version 2: the reasoning effort a session was opened at,
Expand Down Expand Up @@ -123,6 +124,14 @@ func addSessionProfile(tx *sql.Tx) error {
return err
}

// addSessionForked is version 10: whether the session was born branching
// another's conversation (MarkForked). The origin columns cannot say it: a
// delegate and a subagent card carry an origin too.
func addSessionForked(tx *sql.Tx) error {
_, err := tx.Exec(`ALTER TABLE sessions ADD COLUMN forked INTEGER NOT NULL DEFAULT 0`)
return err
}

// NewerSchemaError is an open refused because the database was written by a
// newer lich than this one. Opening it anyway would let this binary write its
// defaults over columns it does not know about, so it is not opened at all.
Expand Down
20 changes: 15 additions & 5 deletions internal/store/mutations.go
Original file line number Diff line number Diff line change
Expand Up @@ -364,14 +364,14 @@ func (s *Service) reopen(newSessionID, where string, args ...any) (*Session, err
row := tx.QueryRow(
`SELECT id, project_id, label, kind, path, provider_session_id, label_auto,
model, effort, ultracode, subagent, entrypoint, sandbox, pinned, folder, color, origin_session_id, origin_label,
scheduled_at, scheduled_prompt, fork_cost_offset, profile
forked, scheduled_at, scheduled_prompt, fork_cost_offset, profile
FROM sessions `+where,
args...,
)
if err := row.Scan(
&old.ID, &projectID, &old.Label, &old.Kind, &old.Path, &old.ProviderSessionID,
&labelAuto, &model, &effort, &ultracode, &subagent, &entrypoint, &sandbox, &old.Pinned, &folder, &color,
&old.OriginSessionID, &old.OriginLabel,
&old.OriginSessionID, &old.OriginLabel, &old.Forked,
&old.ScheduledAt, &old.ScheduledPrompt, &forkOffset, &profile,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
Expand Down Expand Up @@ -422,11 +422,11 @@ func (s *Service) reopen(newSessionID, where string, args ...any) (*Session, err
`INSERT INTO sessions
(id, project_id, label, kind, path, provider_session_id, label_auto,
model, effort, ultracode, subagent, entrypoint, sandbox, pinned, folder, color, origin_session_id, origin_label,
scheduled_at, scheduled_prompt, fork_cost_offset, profile, position)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, `+nextSessionPosition+`)`,
forked, scheduled_at, scheduled_prompt, fork_cost_offset, profile, position)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, `+nextSessionPosition+`)`,
newSessionID, projectID, old.Label, old.Kind, old.Path, old.ProviderSessionID, labelAuto,
model, effort, ultracode, subagent, entrypoint, sandbox, old.Pinned, folder, color, old.OriginSessionID, old.OriginLabel,
old.ScheduledAt, old.ScheduledPrompt, forkOffset, profile, projectID,
old.Forked, old.ScheduledAt, old.ScheduledPrompt, forkOffset, profile, projectID,
); err != nil {
return fmt.Errorf("reinsert session %q: %w", newSessionID, err)
}
Expand All @@ -453,6 +453,7 @@ func (s *Service) reopen(newSessionID, where string, args ...any) (*Session, err
Profile: profile,
OriginSessionID: old.OriginSessionID,
OriginLabel: old.OriginLabel,
Forked: old.Forked,
ScheduledAt: old.ScheduledAt,
ScheduledPrompt: old.ScheduledPrompt,
}
Expand Down Expand Up @@ -821,6 +822,15 @@ func (s *Service) InheritSubagent(sessionID, forkedFrom string) error {
return nil
}

// MarkForked records that sessionID was born branching its origin's
// conversation, written by the window that opens the fork along with the row.
func (s *Service) MarkForked(sessionID string) error {
if _, err := s.db.Exec(`UPDATE sessions SET forked = 1 WHERE id = ?`, sessionID); err != nil {
return fmt.Errorf("mark %q forked: %w", sessionID, err)
}
return nil
}

// InheritUltracode turns ultracode on for a fork when the session that ran the
// conversation it branches, forkedFrom, has it on. The fork's row is a fresh
// insert that knows nothing of its parent, and Claude Code drops ultracode on
Expand Down
34 changes: 34 additions & 0 deletions internal/store/origin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,37 @@ func TestReopenWorktreeSessionCarriesTheOrigin(t *testing.T) {
t.Errorf("reloaded origin = %+v, want the parent it was opened from", reloaded)
}
}

// TestMarkForkedRidesTheRowThroughAReopen: a fork is told apart from a
// delegate or a subagent, which carry an origin too, by its own mark, and a
// park and resume is not what makes a card stop being a fork.
func TestMarkForkedRidesTheRowThroughAReopen(t *testing.T) {
svc := newTestStore(t)
_ = svc.AddProject("p1", "alpha", "/tmp/alpha")
_ = svc.AddSession("p1", "keep", "auth-refactor", "claude", "", 1, "")
_ = svc.AddSessionFrom("p1", "fork", "auth-refactor 2", "claude", "", 2, "keep", "auth-refactor", "")
_ = svc.AddSessionFrom("p1", "worker", "worker", "claude", "", 3, "keep", "auth-refactor", "")
if err := svc.MarkForked("fork"); err != nil {
t.Fatalf("MarkForked: %v", err)
}

forked := func() map[string]bool {
projects, _ := svc.LoadState()
got := map[string]bool{}
for _, s := range projects[0].Sessions {
got[s.ID] = s.Forked
}
return got
}
if got := forked(); !got["fork"] || got["worker"] || got["keep"] {
t.Fatalf("forked = %v, want only the fork marked", got)
}
_ = svc.CloseSession("p1", "fork", "keep")
restored, err := svc.ReopenSession("fork", "fork-again")
if err != nil || restored == nil || !restored.Forked {
t.Fatalf("reopen = %+v, %v; want the mark carried", restored, err)
}
if !forked()["fork-again"] {
t.Error("the reopened row lost its fork mark on reload")
}
}
7 changes: 5 additions & 2 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,9 @@ type Session struct {
Profile string `json:"profile"`
OriginSessionID string `json:"originSessionId"`
OriginLabel string `json:"originLabel"`
// Forked is whether the session was born branching its origin's
// conversation, rather than delegated or opened as a subagent.
Forked bool `json:"forked"`
// ScheduledAt is when the prompt below is due, in unix seconds, 0 for a
// session with nothing waiting. It rides the session row so the card that
// draws the countdown and the relay that delivers it read the same hydration
Expand Down Expand Up @@ -690,7 +693,7 @@ func (s *Service) ProjectAt(path string) (string, string) {
func (s *Service) sessionsOf(projectID string) ([]Session, error) {
rows, err := s.db.Query(
`SELECT id, label, kind, path, provider_session_id, entrypoint, sandbox, pinned,
folder, color, profile, origin_session_id, origin_label, scheduled_at, scheduled_prompt, unread,
folder, color, profile, origin_session_id, origin_label, forked, scheduled_at, scheduled_prompt, unread,
mcp_servers, sandbox_links,
EXISTS (SELECT 1 FROM session_last_turn WHERE session_id = sessions.id)
FROM sessions WHERE project_id = ? AND is_open = 1 ORDER BY position, rowid`,
Expand All @@ -708,7 +711,7 @@ func (s *Service) sessionsOf(projectID string) ([]Session, error) {
if err := rows.Scan(
&sess.ID, &sess.Label, &sess.Kind, &sess.Path, &sess.ProviderSessionID,
&sess.Entrypoint, &sess.Sandbox, &sess.Pinned, &sess.Folder, &sess.Color, &sess.Profile,
&sess.OriginSessionID, &sess.OriginLabel,
&sess.OriginSessionID, &sess.OriginLabel, &sess.Forked,
&sess.ScheduledAt, &sess.ScheduledPrompt, &sess.Unread, &servers, &links,
&sess.HasLastTurn,
); err != nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/store/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -969,7 +969,7 @@ func TestOpenDropsTheTerminalThemeSetting(t *testing.T) {
_ = before.SetSetting("appearance.terminalTheme", "p1", "emerald")
_ = before.SetSetting("appearance.theme", "", "rose-pine")
_ = before.Close()
seedDB(t, path, `ALTER TABLE sessions DROP COLUMN effort; ALTER TABLE sessions DROP COLUMN ultracode; ALTER TABLE sessions DROP COLUMN subagent; ALTER TABLE session_costs DROP COLUMN reported; ALTER TABLE sessions DROP COLUMN color; ALTER TABLE sessions DROP COLUMN profile; DROP TABLE provider_session_tombstones; DROP TRIGGER sessions_tombstone_ad; DROP TRIGGER sessions_tombstone_ai; DROP TRIGGER sessions_tombstone_au; PRAGMA user_version = 0`)
seedDB(t, path, `ALTER TABLE sessions DROP COLUMN effort; ALTER TABLE sessions DROP COLUMN ultracode; ALTER TABLE sessions DROP COLUMN subagent; ALTER TABLE session_costs DROP COLUMN reported; ALTER TABLE sessions DROP COLUMN color; ALTER TABLE sessions DROP COLUMN profile; ALTER TABLE sessions DROP COLUMN forked; DROP TABLE provider_session_tombstones; DROP TRIGGER sessions_tombstone_ad; DROP TRIGGER sessions_tombstone_ai; DROP TRIGGER sessions_tombstone_au; PRAGMA user_version = 0`)

after, err := open(path)
if err != nil {
Expand Down
Loading
Loading