Skip to content

Commit 55b7f28

Browse files
authored
Merge pull request #57 from illegalstudio/feat/resume-grok
fix: support resuming Grok sessions
2 parents 65712ba + befbd64 commit 55b7f28

11 files changed

Lines changed: 75 additions & 42 deletions

File tree

docs/concepts/session-info.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,10 @@ For agents that expose a resumable CLI, lazyagent builds the exact shell command
4747
- OpenCode: `opencode -s <id>`
4848
- Kilo: `kilo --session=<id>`
4949
- Cursor: `cursor-agent --resume="<id>"`
50+
- Grok: `grok --resume '<session-id>'`
5051
- Kimi Code: `kimi --resume <session-id>`
5152

52-
Grok CLI does not currently expose a direct resume command that lazyagent can invoke, so Grok sessions omit this field. In the TUI, <kbd>c</kbd> copies the command to the clipboard when one exists. The GUI has a copy button next to the command. The API exposes it as `resume_command` on the session detail response.
53+
In the TUI, <kbd>c</kbd> copies the command to the clipboard when one exists. The GUI has a copy button next to the command. The API exposes it as `resume_command` on the session detail response.
5354

5455
## Cost estimation
5556

docs/maintenance/search.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,11 @@ Type a 1-based result number to open that session in the originating agent when
7474
| codex | `codex resume <session-id>` |
7575
| amp | `amp threads continue <session-id>` |
7676
| pi | `pi --session <session-id>` |
77+
| grok | `grok --resume '<session-id>'` |
7778
| kimi | `kimi --resume <session-id>` |
7879

7980
The command runs from the session's original CWD when that directory still exists, otherwise from the current shell directory. Pressing <kbd>Enter</kbd> on an empty line exits without opening anything.
8081

81-
> **Grok sessions** are indexed and searchable, but `search` cannot resume them directly — Grok exposes no resume command, so selecting a Grok result reports that and exits.
82-
8382
## Index management
8483

8584
The index updates *incrementally* on every run — there's normally no reason to think about it. Two situations call for a manual rebuild:

docs/usage/cli.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,9 @@ lazyagent search --agent codex "parser"
171171
lazyagent search --reindex "config"
172172
```
173173

174-
After printing results in an interactive terminal, `search` prompts for a result number; entering one opens that chat via the originating agent's resume command when lazyagent knows one. Grok sessions are searchable but not directly resumable, because Grok CLI does not expose a resume command. Piped output stays non-interactive.
174+
After printing results in an interactive terminal, `search` prompts for a result number; entering one opens that chat via the originating agent's resume command when lazyagent knows one. Grok sessions resume with `grok --resume '<session-id>'`. Piped output stays non-interactive.
175175

176-
Full reference, including the index location, ranking, resume commands, and Grok caveat: [`search`](../maintenance/search.md).
176+
Full reference, including the index location, ranking, and resume commands: [`search`](../maintenance/search.md).
177177

178178
### `limits`
179179

docs/usage/history.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ when it still exists. Pressing Enter on an empty line — or `ctrl+c` —
5353
quits without resuming. Piped or redirected output skips the prompt
5454
entirely, so `lazyagent history | cat` stays non-interactive.
5555

56-
Agents lazyagent can exec directly: Claude Code, Codex, Amp, pi, and Kimi.
56+
Agents lazyagent can exec directly: Claude Code, Codex, Amp, pi, Grok, and Kimi.
5757
Other agents print a "no resume command" notice instead. For scripted
5858
output, `lazyagent sessions --json` emits the same list as JSON.
5959

docs/usage/latest.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Opening: claude --resume 3f2a…
2424

2525
The resume command runs with this terminal attached, from the session's own
2626
working directory when it still exists. Agents lazyagent can exec directly:
27-
Claude Code, Codex, Amp, pi, and Kimi; for other agents a "no resume
27+
Claude Code, Codex, Amp, pi, Grok, and Kimi; for other agents a "no resume
2828
command" notice is printed instead. "Most recent" means latest activity in
2929
the transcript (lazyagent does not record a separate creation time).
3030

docs/usage/sessions.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ lazyagent sessions [--agent NAME] [--json] [--dir PATH]
2222
┌─ Sessions in ~/projects/foo (12) ───────────────────────────┐
2323
│ ▸ claude 2h ago 84 fix build embed placeholder │
2424
│ codex yesterday 31 webhook config models │
25-
│ grok 3d ago 12 docs limits (no resume)
25+
│ grok 3d ago 12 docs limits
2626
└─────────────────────────────────────────────────────────────┘
2727
↑/↓ move · enter open · c copy resume cmd · q quit
2828
```
@@ -41,8 +41,8 @@ user message).
4141
**Opening** runs the agent's resume command (e.g. `claude --resume <id>`)
4242
with this terminal attached, from the session's own working directory when
4343
it still exists. Agents lazyagent can exec directly: Claude Code, Codex,
44-
Amp, pi, and Kimi. For OpenCode, Kilo, and Cursor the resume command is
45-
copied to the clipboard instead; Grok has no resume command.
44+
Amp, pi, Grok, and Kimi. For OpenCode, Kilo, and Cursor the resume command
45+
is copied to the clipboard instead.
4646

4747
## Flags
4848

internal/core/resume.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package core
22

3-
import "fmt"
3+
import (
4+
"fmt"
5+
"strings"
6+
)
47

58
// ResumeCommand returns the CLI command to resume a session for the given agent.
69
// Returns empty string for unknown agents or empty session IDs.
@@ -23,16 +26,23 @@ func ResumeCommand(agent, sessionID string) string {
2326
return fmt.Sprintf("kilo --session=%s", sessionID)
2427
case "cursor":
2528
return fmt.Sprintf("cursor-agent --resume=%q", sessionID)
29+
case "grok":
30+
return fmt.Sprintf("grok --resume %s", shellQuoteArg(sessionID))
2631
case "kimi":
2732
return fmt.Sprintf("kimi --resume %s", sessionID)
2833
default:
2934
return ""
3035
}
3136
}
3237

38+
// shellQuoteArg returns one POSIX shell word using single-quote escaping.
39+
func shellQuoteArg(value string) string {
40+
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
41+
}
42+
3343
// ResumeArgv returns the executable argv to resume a session, or nil when
34-
// the agent has no resume command lazyagent is willing to exec (grok has
35-
// none; opencode/kilo/cursor have a display string onlysee ResumeCommand).
44+
// the agent has no resume command lazyagent is willing to exec.
45+
// OpenCode, Kilo, and Cursor have a display string only; see ResumeCommand.
3646
// "Openable" everywhere in the codebase means ResumeArgv != nil.
3747
func ResumeArgv(agent, sessionID string) []string {
3848
if sessionID == "" {
@@ -47,6 +57,8 @@ func ResumeArgv(agent, sessionID string) []string {
4757
return []string{"amp", "threads", "continue", sessionID}
4858
case "pi":
4959
return []string{"pi", "--session", sessionID}
60+
case "grok":
61+
return []string{"grok", "--resume", sessionID}
5062
case "kimi":
5163
return []string{"kimi", "--resume", sessionID}
5264
default:

internal/core/resume_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ func TestResumeCommand(t *testing.T) {
1616
{"opencode", "abc-123", "opencode -s abc-123"},
1717
{"kilo", "abc-123", "kilo --session=abc-123"},
1818
{"cursor", "abc-123", `cursor-agent --resume="abc-123"`},
19+
{"grok", "abc-123", "grok --resume 'abc-123'"},
20+
{"grok", "abc $(touch /tmp/pwn) 'quoted'", `grok --resume 'abc $(touch /tmp/pwn) '\''quoted'\'''`},
21+
{"kimi", "abc-123", "kimi --resume abc-123"},
1922
{"unknown", "abc-123", ""},
2023
{"claude", "", ""},
2124
{"", "abc-123", ""},
@@ -37,12 +40,12 @@ func TestResumeArgv(t *testing.T) {
3740
{"codex", []string{"codex", "resume", "abc"}},
3841
{"amp", []string{"amp", "threads", "continue", "abc"}},
3942
{"pi", []string{"pi", "--session", "abc"}},
43+
{"grok", []string{"grok", "--resume", "abc"}},
4044
{"kimi", []string{"kimi", "--resume", "abc"}},
4145
// Display-only agents: copyable via ResumeCommand but not executable.
4246
{"opencode", nil},
4347
{"kilo", nil},
4448
{"cursor", nil},
45-
{"grok", nil},
4649
{"unknown", nil},
4750
}
4851
for _, c := range cases {

internal/search/query_test.go

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -78,19 +78,28 @@ func TestNormalizeArgsAllowsFlagsAfterQuery(t *testing.T) {
7878
}
7979

8080
func TestResumeCommand(t *testing.T) {
81-
cmd, display := resumeCommand("codex", "abc123")
82-
if cmd == nil {
83-
t.Fatal("resumeCommand returned nil")
81+
tests := []struct {
82+
agent, executable, wantArgs, wantDisplay string
83+
}{
84+
{"codex", "codex", "codex resume abc123", "codex resume abc123"},
85+
{"grok", "grok", "grok --resume abc123", "grok --resume 'abc123'"},
8486
}
85-
if filepath.Base(cmd.Path) != "codex" {
86-
t.Fatalf("cmd.Path = %q, want codex executable", cmd.Path)
87-
}
88-
got := strings.Join(cmd.Args, " ")
89-
want := "codex resume abc123"
90-
if got != want {
91-
t.Fatalf("args = %q, want %q", got, want)
92-
}
93-
if display != want {
94-
t.Fatalf("display = %q, want %q", display, want)
87+
for _, tt := range tests {
88+
t.Run(tt.agent, func(t *testing.T) {
89+
cmd, display := resumeCommand(tt.agent, "abc123")
90+
if cmd == nil {
91+
t.Fatal("resumeCommand returned nil")
92+
}
93+
if filepath.Base(cmd.Path) != tt.executable {
94+
t.Fatalf("cmd.Path = %q, want %s executable", cmd.Path, tt.executable)
95+
}
96+
got := strings.Join(cmd.Args, " ")
97+
if got != tt.wantArgs {
98+
t.Fatalf("args = %q, want %q", got, tt.wantArgs)
99+
}
100+
if display != tt.wantDisplay {
101+
t.Fatalf("display = %q, want %q", display, tt.wantDisplay)
102+
}
103+
})
95104
}
96105
}

internal/sessions/json_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,13 @@ func TestWriteJSONAllFieldsPresentEvenWhenEmpty(t *testing.T) {
7777
_ = v
7878
}
7979
}
80-
// grok has no resume command and neither session has a name: both must
81-
// be present as empty strings, not omitted.
80+
// Neither session has a name, so both names must be present as empty
81+
// strings rather than omitted.
8282
if out[0]["name"] != "" {
8383
t.Errorf("grok row name = %v, want empty string", out[0]["name"])
8484
}
85-
if out[0]["resume_command"] != "" {
86-
t.Errorf("grok row resume_command = %v, want empty string", out[0]["resume_command"])
85+
if out[0]["resume_command"] != "grok --resume 'g1'" {
86+
t.Errorf("grok row resume_command = %v, want grok --resume 'g1'", out[0]["resume_command"])
8787
}
8888
if out[1]["name"] != "" {
8989
t.Errorf("claude row name = %v, want empty string", out[1]["name"])

0 commit comments

Comments
 (0)