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
9 changes: 9 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,15 @@ price".

## Query interface

A session id can be in several accounts: each is a copy, its own
transcript, and the index keys sessions by account and id. Commands that
read one session (`show`, `log`, `export`, `view`) take `<account>:<id>` or
`-a`, and otherwise use the copy with the latest activity and name the
others on stderr (`query.DB.CopiesOf`). `Turns` marks each turn whose prompt
lies in a byte range the ledger says ccli copied in (`TurnRow.CopiedFrom`:
the source account, or `account:id` for a fork). Marks that belong to the
id (hide, tag) apply to every copy.

Every command reads through `query.DB`; the terminal renderer only formats
its results, and every result serializes as JSON. The main calls are
`Accounts(filter)`, `Sessions(filter)`, `Session(ref)`, `Overview(filter)`
Expand Down
40 changes: 38 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -489,8 +489,44 @@ and per-day figures) on `sessions`, `show`, `log`, `stats`, `quota`,
`accounts` and `view`. On `view` it starts with tool calls expanded. Both
can sit before the command or after it.

`ccli <command> -h` lists a command's flags. Session ids can be shortened to
any unique prefix, or written `account:id` to pick one account's copy.
`ccli <command> -h` lists a command's flags.

### Sessions, copies and accounts

| Thing | What it is | How you name it |
| --- | --- | --- |
| account | a folder a tool keeps its sessions in: `~/.claude` is `default`, `~/.claude-work` is `work`, and Codex, pi and Cursor homes | its name, after `-a` |
| session | one conversation, known by its id | the id, or any unique prefix of at least 4 characters |
| copy | the same session, with the same id, in another account; `copy`, `move` and `resume -a` make one. Each copy is its own transcript. Copies share their beginning, and each counts only the work done in it | `<account>:<id>`, or the id with `-a <account>` |
| fork | a new session with a new id, made from a session, whole or through one turn | its own id |
| turn | a prompt and the work it led to, numbered in `ccli log` | `ccli log <id> <turn>`, `ccli fork <id> --turn <turn>` |
| agent | a subagent's transcript; it belongs to the session that started it | `ccli log <id> --agent <agent id>` |

When a session is in more than one account and you name no account, ccli
uses the copy used last and says so on stderr:

```
ccli: 1087b979 is also in default; this is the copy in talcum, the one used last (-a default picks another)
```

`-a` always names the account you are looking at or working in:

| Commands | Work on | `-a` means |
| --- | --- | --- |
| `sessions`, `prompts`, `projects`, `stats`, `quota`, `clean` | many sessions | only these accounts (repeat it or separate with commas) |
| `show`, `log`, `export`, `view <id>` | one copy | read the copy in this account |
| `view` without an id | the session list | list only this account |
| `resume` | one copy | resume in this account, after bringing its copy up to date |
| `fork` | a copy, into a new session | put the new session in this account; `--from` or `<account>:<id>` picks the copy to fork |
| `copy`, `move` | a copy, into another account | not used: `--from` and `--to` name both ends |
| `diff` | two accounts | not used: name the two accounts |
| `hide`, `unhide`, `tag` | every copy of the id | not used: the mark belongs to the session id |

In a copy's `ccli log`, the turns that came in with the copy show `copied`
in the COST column, and a line under the table says where they came from
and where their usage counts. ccli knows this for the copies it made; a
transcript copied by hand has no such record, and its copied turns simply
show no cost.

## How it counts

Expand Down
24 changes: 24 additions & 0 deletions docs/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,30 @@ To bring work's sessions into default: ccli copy --from work --to default (prin
"work is ahead" means default's transcript is an exact prefix of work's: the
session went on in work after default last saw it.

### Which copy you are looking at

`746cbfa4` is in both accounts under the same id. `show`, `log`, `export`
and `view` read one copy: the one you name with `-a` (or as
`default:746cbfa4`), or else the one used last, and they say which on
stderr:

```
$ ccli log 746cbfa4
ccli: 746cbfa4 is also in work; this is the copy in default, the one used last (-a work picks another)
Why does the nightly export take forty minutes now?
746cbfa4 · default · 25 prompts typed · $0
```

In a copy's turn list, the turns that came in with a copy show `copied` in
the COST column, and a line under the table explains them:

```
turns 18 to 25 came with a copy from work; their usage counts in work (ccli log 746cbfa4 -a work)
```

The README's "Sessions, copies and accounts" table lists what every command
works on and what `-a` means for it.

### Continue a session in the other account, and come back

Say `746cbfa4` went on in work, and now you want to continue it in default.
Expand Down
11 changes: 8 additions & 3 deletions internal/cli/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,27 @@ func cmdExport(ctx context.Context, a *app, args []string) error {
thinking := fs.Bool("thinking", false, "include the model's thinking")
withContext := fs.Bool("context", false, "include text the tool added (skills, reminders, peer messages)")
all := fs.Bool("all", false, "include turns started by tasks, other sessions and the system")
in := copyFlag(fs)
pos, err := parse(fs, args)
if err != nil {
return err
}
if len(pos) < 1 || len(pos) > 2 {
return errors.New("usage: ccli export <session> [turn | from-to | last] [--out file] [--format md|jsonl]")
return errors.New("usage: ccli export [-a account] <session> [turn | from-to | last] [--out file] [--format md|jsonl]")
}
svc, err := a.prepare(ctx, c, nil)
if pos[0], err = withAccount(pos[0], *in); err != nil {
return err
}
svc, err := a.prepare(ctx, c, accountsOf(*in))
if err != nil {
return err
}
defer a.close()
acct, _, err := svc.Resolve(ctx, pos[0])
acct, id, err := svc.Resolve(ctx, pos[0])
if err != nil {
return err
}
a.noteCopy(ctx, svc, pos[0], acct, id)
a.refreshCursorFor(ctx, []string{acct}, nil)
ref := pos[0]
list, err := svc.Turns(ctx, ref)
Expand Down
11 changes: 8 additions & 3 deletions internal/cli/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,16 @@ func cmdLog(ctx context.Context, a *app, args []string) error {
agent := fs.String("agent", "", "print a subagent's transcript (agent id or prefix)")
run := fs.String("run", "", "list a workflow run's agents (run id or prefix)")
noPager := fs.Bool("no-pager", false, "do not page long output")
in := copyFlag(fs)
pos, err := parse(fs, args)
if err != nil {
return err
}
if len(pos) < 1 || len(pos) > 2 {
return errors.New("usage: ccli log <session> [turn | from-to | last] [--detail full|reply] [--tools] [--thinking] [--agent id] [--run id]")
return errors.New("usage: ccli log [-a account] <session> [turn | from-to | last] [--detail full|reply] [--tools] [--thinking] [--agent id] [--run id]")
}
if pos[0], err = withAccount(pos[0], *in); err != nil {
return err
}
switch strings.ToLower(strings.TrimSpace(*detail)) {
case "", "full", "reply":
Expand All @@ -58,12 +62,13 @@ func cmdLog(ctx context.Context, a *app, args []string) error {
if a.verbose && !reply {
opt.Tools = true
}
svc, err := a.prepare(ctx, c, nil)
svc, err := a.prepare(ctx, c, accountsOf(*in))
if err != nil {
return err
}
defer a.close()
if acct, _, err := svc.Resolve(ctx, pos[0]); err == nil {
if acct, id, err := svc.Resolve(ctx, pos[0]); err == nil {
a.noteCopy(ctx, svc, pos[0], acct, id)
a.refreshCursorFor(ctx, []string{acct}, nil)
}
ref := pos[0]
Expand Down
28 changes: 28 additions & 0 deletions internal/cli/transfer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,31 @@ func TestResumeRejectsBothSyncFlags(t *testing.T) {
t.Fatalf("exit %d\n%s", code, errw.String())
}
}

// With the session in two accounts, the read commands say which copy they
// show, and -a (or account:id) picks the other; log marks the turns that
// came in with the copy.
func TestReadCommandsPickACopy(t *testing.T) {
twoAccounts(t)
runCLI(t, "copy", roundTrip, "--to", "work", "--apply")
for _, cmd := range [][]string{{"show", roundTrip}, {"log", roundTrip}, {"export", roundTrip}} {
var out, errw bytes.Buffer
if code := Main(cmd, &out, &errw); code != 0 || !strings.Contains(errw.String(), "is also in") {
t.Fatalf("%v: exit %d, stderr %q", cmd, code, errw.String())
}
}
work := string(runCLI(t, "log", roundTrip, "-a", "work"))
if !strings.Contains(work, "· work ·") || !strings.Contains(work, "turns 1 to 2 came with a copy from default") || !strings.Contains(work, "copied") {
t.Fatalf("log -a work:\n%s", work)
}
if def := string(runCLI(t, "log", "default:"+roundTrip)); !strings.Contains(def, "· default ·") || strings.Contains(def, "came with a copy") {
t.Fatalf("log default:id:\n%s", def)
}
var out, errw bytes.Buffer
if code := Main([]string{"log", "default:" + roundTrip, "-a", "work"}, &out, &errw); code == 0 || !strings.Contains(errw.String(), "different accounts") {
t.Fatalf("conflicting accounts: exit %d\n%s", code, errw.String())
}
if code := Main([]string{"show", roundTrip, "-a", "nosuch"}, &out, &errw); code == 0 {
t.Fatal("an unknown account was accepted")
}
}
13 changes: 11 additions & 2 deletions internal/cli/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,28 @@ func cmdView(ctx context.Context, a *app, args []string) error {
dirFlag(fs, &scope)
toolFlag(fs, &scope)
tools := fs.Bool("tools", false, "expand tool calls inside each turn")
in := fs.String("a", "", "with a session: its copy in this account (same as <account>:<id>); without one: list only this account")
pos, err := parse(fs, args)
if err != nil {
return err
}
if len(pos) > 1 {
return errors.New("usage: ccli view [session]")
return errors.New("usage: ccli view [-a account] [session]")
}
if len(pos) == 1 {
if pos[0], err = withAccount(pos[0], *in); err != nil {
return err
}
} else if *in != "" {
scope.Accounts = []string{*in}
}
if len(pos) == 1 && (len(scope.Dirs) > 0 || len(scope.Tools) > 0) {
return errors.New("-dir and -tool narrow the session list; leave them out when opening one session")
}
if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
return errors.New("ccli view needs a terminal; ccli log prints the same turns")
}
svc, err := a.prepare(ctx, c, nil)
svc, err := a.prepare(ctx, c, accountsOf(*in))
if err != nil {
return err
}
Expand All @@ -48,6 +56,7 @@ func cmdView(ctx context.Context, a *app, args []string) error {
if err != nil {
return err
}
a.noteCopy(ctx, svc, pos[0], acct, id)
ref = acct + ":" + id
a.refreshCursorFor(ctx, []string{acct}, nil)
} else {
Expand Down
74 changes: 64 additions & 10 deletions internal/cli/views.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,9 +290,8 @@ func cmdSessions(ctx context.Context, a *app, args []string) error {

func cmdShow(ctx context.Context, a *app, args []string) error {
var c common
var accts accountsFlag
fs := newFlags("show", a, &c)
fs.Var(&accts, "a", "account, when the session exists in more than one")
in := copyFlag(fs)
all := fs.Bool("all", false, "list every subagent")
pos, err := parse(fs, args)
if err != nil {
Expand All @@ -301,22 +300,20 @@ func cmdShow(ctx context.Context, a *app, args []string) error {
if len(pos) != 1 {
return errors.New("usage: ccli show [-a account] <session id or prefix>")
}
if len(accts) > 1 {
return errors.New("show takes one account")
}
ref := pos[0]
if len(accts) == 1 && !strings.Contains(ref, ":") {
ref = accts[0] + ":" + ref
ref, err := withAccount(pos[0], *in)
if err != nil {
return err
}
svc, err := a.prepare(ctx, c, accts)
svc, err := a.prepare(ctx, c, accountsOf(*in))
if err != nil {
return err
}
defer a.close()
acct, _, err := svc.Resolve(ctx, ref)
acct, id, err := svc.Resolve(ctx, ref)
if err != nil {
return err
}
a.noteCopy(ctx, svc, ref, acct, id)
a.refreshCursorFor(ctx, []string{acct}, nil)
d, err := svc.Session(ctx, ref)
if err != nil {
Expand Down Expand Up @@ -469,6 +466,63 @@ func withoutVar(env []string, name string) []string {
return out
}

// copyFlag adds -a to a command that reads one session: its copy in that
// account, when the session is in more than one. It means the same as
// writing the reference as <account>:<id>.
func copyFlag(fs *flag.FlagSet) *string {
return fs.String("a", "", "the session's copy in this account, when it is in more than one (same as <account>:<id>)")
}

// withAccount writes an -a account into a session reference. It refuses a
// reference that already names another account.
func withAccount(ref, acct string) (string, error) {
if acct == "" {
return ref, nil
}
if i := strings.IndexByte(ref, ':'); i > 0 {
if ref[:i] != acct {
return "", fmt.Errorf("-a %s and %s name different accounts; give one", acct, ref)
}
return ref, nil
}
return acct + ":" + ref, nil
}

// accountsOf is the account list prepare checks: the -a account, if any.
func accountsOf(acct string) []string {
if acct == "" {
return nil
}
return []string{acct}
}

// noteCopy says on stderr which copy a reference that names no account
// resolved to, when the session is in more than one account.
func (a *app) noteCopy(ctx context.Context, svc *query.DB, ref, acct, id string) {
if strings.Contains(ref, ":") {
return
}
all, err := svc.CopiesOf(ctx, id)
if err != nil {
return
}
var others []string
for _, o := range all {
if o != acct {
others = append(others, o)
}
}
if len(others) == 0 {
return
}
pick := others[0]
if len(others) > 1 {
pick = "<account>"
}
fmt.Fprintf(a.errw, "ccli: %s is also in %s; this is the copy in %s, the one used last (-a %s picks another)\n",
query.ShortID(id), strings.Join(others, ", "), acct, pick)
}

// flagSet reports whether the command line gave the flag.
func flagSet(fs *flag.FlagSet, name string) bool {
set := false
Expand Down
18 changes: 18 additions & 0 deletions internal/query/detail.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,24 @@ type Resume struct {
// ErrNotFound is returned when no session matches a reference.
var ErrNotFound = errors.New("no session matches")

// CopiesOf lists the accounts that hold a session, by name.
func (d *DB) CopiesOf(ctx context.Context, id string) ([]string, error) {
rows, err := d.sql().QueryContext(ctx, `SELECT DISTINCT account FROM files WHERE session_id = ? AND kind = 'main' ORDER BY account`, id)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var a string
if err := rows.Scan(&a); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}

// Resolve finds the session for a reference: a full id or a unique prefix,
// optionally "account:" in front.
func (d *DB) Resolve(ctx context.Context, ref string) (account, id string, err error) {
Expand Down
Loading
Loading