rl-workspaces 0.0.0-stage → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +735 -2
  3. package/bin/rl +54 -0
  4. package/config.example.zsh +9 -0
  5. package/dist/access.js +40 -0
  6. package/dist/activity.js +90 -0
  7. package/dist/agent-claude.js +16 -0
  8. package/dist/agent-codex.js +22 -0
  9. package/dist/agent-launch.js +74 -0
  10. package/dist/agent-presets.js +70 -0
  11. package/dist/agent-registry.js +8 -0
  12. package/dist/agent-resume.js +96 -0
  13. package/dist/agent-runner.js +386 -0
  14. package/dist/agent-workflows.js +275 -0
  15. package/dist/agents.js +73 -0
  16. package/dist/automation-config.js +381 -0
  17. package/dist/base.js +66 -0
  18. package/dist/cleanup-path.js +33 -0
  19. package/dist/cli.js +57 -0
  20. package/dist/codex-sessions.js +188 -0
  21. package/dist/command-context.js +67 -0
  22. package/dist/common.js +262 -0
  23. package/dist/delete.js +113 -0
  24. package/dist/display.js +134 -0
  25. package/dist/doctor.js +145 -0
  26. package/dist/git-safety.js +24 -0
  27. package/dist/git-workflow.js +89 -0
  28. package/dist/help.js +726 -0
  29. package/dist/hooks.js +159 -0
  30. package/dist/json.js +34 -0
  31. package/dist/mcp.js +336 -0
  32. package/dist/picker.js +182 -0
  33. package/dist/pr-discovery.js +223 -0
  34. package/dist/pr-update.js +419 -0
  35. package/dist/pr-workflow.js +161 -0
  36. package/dist/repos.js +537 -0
  37. package/dist/review-policy.js +150 -0
  38. package/dist/session-workflows.js +650 -0
  39. package/dist/session.js +12 -0
  40. package/dist/state.js +250 -0
  41. package/dist/storage.js +366 -0
  42. package/dist/store.js +379 -0
  43. package/dist/summary.js +164 -0
  44. package/dist/task-state.js +65 -0
  45. package/dist/task.js +192 -0
  46. package/dist/templates.js +4 -0
  47. package/dist/workflow-collaboration.js +531 -0
  48. package/dist/workflow-config.js +177 -0
  49. package/dist/workflow.js +211 -0
  50. package/lib/rl.zsh +329 -0
  51. package/node_modules/graceful-fs/LICENSE +15 -0
  52. package/node_modules/graceful-fs/README.md +143 -0
  53. package/node_modules/graceful-fs/clone.js +23 -0
  54. package/node_modules/graceful-fs/graceful-fs.js +448 -0
  55. package/node_modules/graceful-fs/legacy-streams.js +118 -0
  56. package/node_modules/graceful-fs/package.json +53 -0
  57. package/node_modules/graceful-fs/polyfills.js +355 -0
  58. package/node_modules/proper-lockfile/LICENSE +21 -0
  59. package/node_modules/proper-lockfile/README.md +183 -0
  60. package/node_modules/proper-lockfile/index.js +40 -0
  61. package/node_modules/proper-lockfile/lib/adapter.js +85 -0
  62. package/node_modules/proper-lockfile/lib/lockfile.js +342 -0
  63. package/node_modules/proper-lockfile/lib/mtime-precision.js +55 -0
  64. package/node_modules/proper-lockfile/package.json +71 -0
  65. package/node_modules/retry/.npmignore +3 -0
  66. package/node_modules/retry/.travis.yml +15 -0
  67. package/node_modules/retry/License +21 -0
  68. package/node_modules/retry/Makefile +18 -0
  69. package/node_modules/retry/README.md +227 -0
  70. package/node_modules/retry/equation.gif +0 -0
  71. package/node_modules/retry/example/dns.js +31 -0
  72. package/node_modules/retry/example/stop.js +40 -0
  73. package/node_modules/retry/index.js +1 -0
  74. package/node_modules/retry/lib/retry.js +100 -0
  75. package/node_modules/retry/lib/retry_operation.js +158 -0
  76. package/node_modules/retry/package.json +32 -0
  77. package/node_modules/retry/test/common.js +10 -0
  78. package/node_modules/retry/test/integration/test-forever.js +24 -0
  79. package/node_modules/retry/test/integration/test-retry-operation.js +258 -0
  80. package/node_modules/retry/test/integration/test-retry-wrap.js +101 -0
  81. package/node_modules/retry/test/integration/test-timeouts.js +69 -0
  82. package/node_modules/signal-exit/LICENSE.txt +16 -0
  83. package/node_modules/signal-exit/README.md +39 -0
  84. package/node_modules/signal-exit/index.js +202 -0
  85. package/node_modules/signal-exit/package.json +38 -0
  86. package/node_modules/signal-exit/signals.js +53 -0
  87. package/package.json +57 -4
  88. package/shell/rl.zsh +11 -0
package/bin/rl ADDED
@@ -0,0 +1,54 @@
1
+ #!/bin/zsh -f
2
+ emulate -L zsh
3
+ readonly rl_home="${${(%):-%x}:A:h:h}"
4
+ if [[ "$1" == __hook ]]; then
5
+ # Hooks resolve the installed CLI on PATH, not a version-specific package path.
6
+ # Their explicit repository/state arguments must not be replaced by user config.
7
+ shift
8
+ exec node "$rl_home/dist/cli.js" state "$@"
9
+ fi
10
+ if [[ "$1" == --version || "$1" == -V ]]; then
11
+ exec node -p 'JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).version' "$rl_home/package.json"
12
+ fi
13
+ if [[ "$1" == init ]]; then
14
+ if (( $# == 1 )); then
15
+ exec node "$rl_home/dist/help.js" init
16
+ fi
17
+ for argument in "$@"; do
18
+ [[ "$argument" == -- ]] && break
19
+ if [[ "$argument" == --help || "$argument" == -h ]]; then
20
+ exec node "$rl_home/dist/help.js" "$@"
21
+ fi
22
+ done
23
+ if [[ "$2" != zsh && "$2" != bash || $# != 2 ]]; then
24
+ print -u2 'Usage: rl setup shell <zsh|bash>'
25
+ exit 1
26
+ fi
27
+ if [[ "$2" == zsh ]]; then
28
+ print -r -- "source ${(q)rl_home}/shell/rl.zsh"
29
+ else
30
+ print -r -- "_rl_executable=${(qq)rl_home}/bin/rl"
31
+ cat <<'BASH'
32
+ rl() {
33
+ local destination_file destination result previous="$PWD"
34
+ local -a arguments=("$@")
35
+ if [[ "$1" == switch && "${2:-}" == - ]]; then
36
+ [[ -n "${RL_PREVIOUS_WORKSPACE:-}" ]] || { printf '%s\n' 'No previous workspace in this shell' >&2; return 1; }
37
+ arguments[1]="$RL_PREVIOUS_WORKSPACE"
38
+ fi
39
+ destination_file=$(mktemp "${TMPDIR:-/tmp}/rl-shell.XXXXXXXX") || return 1
40
+ if RL_SHELL_OUTPUT="$destination_file" command "$_rl_executable" "${arguments[@]}"; then result=0; else result=$?; fi
41
+ destination=$(cat -- "$destination_file")
42
+ rm -f -- "$destination_file"
43
+ if [[ -n "$destination" ]]; then
44
+ builtin cd -- "$destination" || return 1
45
+ RL_PREVIOUS_WORKSPACE="$previous"
46
+ fi
47
+ return "$result"
48
+ }
49
+ BASH
50
+ fi
51
+ exit 0
52
+ fi
53
+ source "$rl_home/lib/rl.zsh" || exit 1
54
+ _rl_main "$@"
@@ -0,0 +1,9 @@
1
+ # Copy to ~/.config/rl/config.zsh (or use RL_CONFIG).
2
+ RL_REPO_ROOT="$HOME/Documents/GitHub/my-project"
3
+ RL_WORKTREE_DIR="$HOME/.rl/worktrees"
4
+ RL_REPO_NAME="my-project"
5
+ RL_DEFAULT_BASE="origin/test"
6
+ RL_STATE_DIR="$HOME/.rl/instances"
7
+ # Optional registered repository alias; rl --repo <alias> overrides this.
8
+ # Without it, RL selects by current worktree, then `rl repo use` default.
9
+ # RL_REPO="frontend"
package/dist/access.js ADDED
@@ -0,0 +1,40 @@
1
+ import { timestamp } from "./common.js";
2
+ /** Historical sessions/creation provide a fallback without mistaking metadata edits for use. */
3
+ export function lastAccessed(data) {
4
+ if (!data)
5
+ return undefined;
6
+ const candidates = [
7
+ data.lastAccessedAt,
8
+ data.createdAt,
9
+ ...Object.values(data.sessions)
10
+ .flat()
11
+ .map((session) => session.lastUsedAt),
12
+ ];
13
+ let latest;
14
+ for (const value of candidates) {
15
+ if (typeof value !== "string")
16
+ continue;
17
+ try {
18
+ if (!latest || timestamp(value) > timestamp(latest)) {
19
+ timestamp(value);
20
+ latest = value;
21
+ }
22
+ }
23
+ catch {
24
+ /* Legacy records may not have an access timestamp. */
25
+ }
26
+ }
27
+ return latest;
28
+ }
29
+ export function accessLabel(value, now = Date.now()) {
30
+ if (!value)
31
+ return "—";
32
+ const seconds = Math.max(0, Math.floor((now - Date.parse(value)) / 1000));
33
+ if (seconds < 60)
34
+ return "just now";
35
+ if (seconds < 3600)
36
+ return `${Math.floor(seconds / 60)}m ago`;
37
+ if (seconds < 86400)
38
+ return `${Math.floor(seconds / 3600)}h ago`;
39
+ return `${Math.floor(seconds / 86400)}d ago`;
40
+ }
@@ -0,0 +1,90 @@
1
+ import { object, timestamp } from "./common.js";
2
+ export const STALE_AFTER_MS = 30 * 60 * 1000;
3
+ export function activityView(items = [], at = Date.now()) {
4
+ return items.map((item) => ({
5
+ ...item,
6
+ state: item.state !== "ended" &&
7
+ at - Date.parse(item.observedAt) > STALE_AFTER_MS
8
+ ? "stale"
9
+ : item.state,
10
+ }));
11
+ }
12
+ export function recordActivity(data, agent, event, stamp, contextHash) {
13
+ const records = ((data.activity ??= {})[agent] ??= []);
14
+ let item = records.find((entry) => entry.sessionId === event.sessionId && entry.childId === event.childId);
15
+ if (!item) {
16
+ item = {
17
+ sessionId: event.sessionId,
18
+ childId: event.childId,
19
+ state: "idle",
20
+ lastEvent: event.nativeEvent,
21
+ observedAt: stamp,
22
+ resumable: event.resumable,
23
+ };
24
+ records.push(item);
25
+ }
26
+ // Arrival order is the only portable ordering available. Do not invent a
27
+ // source timestamp or claim this is a process-liveness signal.
28
+ item.state =
29
+ event.type === "context.restored" ||
30
+ event.type === "notification" ||
31
+ event.type === "tool.before" ||
32
+ event.type === "tool.after"
33
+ ? item.state
34
+ : event.type === "session.ended"
35
+ ? "ended"
36
+ : ["turn.started", "child.started"].includes(event.type)
37
+ ? "working"
38
+ : "idle";
39
+ item.lastEvent = event.nativeEvent;
40
+ item.observedAt = stamp;
41
+ if (contextHash)
42
+ item.contextHash = contextHash;
43
+ // Keep one snapshot per identity so ownership remains known across sessions.
44
+ records.sort((a, b) => a.observedAt.localeCompare(b.observedAt));
45
+ ((data.hookHealth ??= {})[agent] ??= {}).lastSuccess = {
46
+ event: event.nativeEvent,
47
+ at: stamp,
48
+ };
49
+ }
50
+ export function validateTracking(data) {
51
+ if (data.activity !== undefined) {
52
+ if (!object(data.activity))
53
+ throw new Error("Invalid activity");
54
+ for (const items of Object.values(data.activity)) {
55
+ if (!Array.isArray(items))
56
+ throw new Error("Invalid activity");
57
+ for (const item of items) {
58
+ if (!object(item) ||
59
+ typeof item.sessionId !== "string" ||
60
+ (item.childId !== undefined && typeof item.childId !== "string") ||
61
+ !["idle", "working", "ended"].includes(String(item.state)) ||
62
+ typeof item.lastEvent !== "string" ||
63
+ typeof item.observedAt !== "string" ||
64
+ typeof item.resumable !== "boolean" ||
65
+ (item.contextHash !== undefined &&
66
+ typeof item.contextHash !== "string"))
67
+ throw new Error("Invalid activity");
68
+ timestamp(item.observedAt);
69
+ }
70
+ }
71
+ }
72
+ if (data.hookHealth !== undefined) {
73
+ if (!object(data.hookHealth))
74
+ throw new Error("Invalid hook health");
75
+ for (const health of Object.values(data.hookHealth)) {
76
+ if (!object(health))
77
+ throw new Error("Invalid hook health");
78
+ for (const key of ["lastSuccess", "lastError"]) {
79
+ const entry = health[key];
80
+ if (entry === undefined)
81
+ continue;
82
+ if (!object(entry) ||
83
+ typeof entry.at !== "string" ||
84
+ typeof entry[key === "lastSuccess" ? "event" : "message"] !== "string")
85
+ throw new Error("Invalid hook health");
86
+ timestamp(entry.at);
87
+ }
88
+ }
89
+ }
90
+ }
@@ -0,0 +1,16 @@
1
+ import { sessionId } from "./common.js";
2
+ import { contextResponse, lifecycleEvents, parseEvent, } from "./agents.js";
3
+ export const claude = {
4
+ name: "claude",
5
+ configPath: ".claude/settings.local.json",
6
+ events: { ...lifecycleEvents },
7
+ extraEvents: { StopFailure: "turn.failed", Notification: "notification" },
8
+ observedId: sessionId,
9
+ resumeArgs: (id) => ["claude", "--resume", sessionId(id)],
10
+ startArgs: (argv) => ["claude", ...argv],
11
+ runArgs: (prompt, argv) => ["claude", "--print", ...argv, "--", prompt],
12
+ parse(payload) {
13
+ return parseEvent(claude, payload);
14
+ },
15
+ response: contextResponse,
16
+ };
@@ -0,0 +1,22 @@
1
+ import { sessionId } from "./common.js";
2
+ import { contextResponse, lifecycleEvents, parseEvent, } from "./agents.js";
3
+ export const codex = {
4
+ name: "codex",
5
+ configPath: ".codex/hooks.json",
6
+ events: { ...lifecycleEvents },
7
+ extraEvents: { Interrupt: "turn.interrupted" },
8
+ observedId(value) {
9
+ // The hook contract also documents thread IDs. Record these for diagnostics,
10
+ // but don't send them to CLI resume until that contract is verified.
11
+ if (typeof value === "string" && /^thr_[A-Za-z0-9_-]{1,128}$/.test(value))
12
+ return value;
13
+ return sessionId(value);
14
+ },
15
+ resumeArgs: (id) => ["codex", "resume", sessionId(id)],
16
+ startArgs: (argv) => ["codex", ...argv],
17
+ runArgs: (prompt, argv) => ["codex", "exec", ...argv, "--", prompt],
18
+ parse(payload) {
19
+ return parseEvent(codex, payload);
20
+ },
21
+ response: contextResponse,
22
+ };
@@ -0,0 +1,74 @@
1
+ import fs from "node:fs";
2
+ import { agentAdapter } from "./agent-registry.js";
3
+ import { args, arity, execute } from "./common.js";
4
+ import { commandContext, contextOptions, resolveWorkspace, } from "./command-context.js";
5
+ import { readWorkflowConfig, workflowEnvironment } from "./workflow-config.js";
6
+ import { contextText } from "./store.js";
7
+ export async function launchAgent(store, name, argv = [], options = {}) {
8
+ const adapter = agentAdapter(name);
9
+ const [, data] = await store.locked(() => store.current(options.cwd));
10
+ const headless = options.mode === "run";
11
+ const prompt = options.prompt ?? "";
12
+ if (headless && !prompt.trim())
13
+ throw new Error("Agent run requires a non-empty --prompt");
14
+ if (!headless && options.prompt !== undefined)
15
+ throw new Error("Use agent run for --prompt");
16
+ const command = headless
17
+ ? adapter.runArgs(prompt, argv)
18
+ : adapter.startArgs(argv);
19
+ const result = await execute(command, data.worktree, {
20
+ interactive: !headless,
21
+ terminalError: headless,
22
+ env: {
23
+ ...workflowEnvironment(store.repo, data.worktree, readWorkflowConfig(store.repo)),
24
+ RL_STATE_DIR: store.stateDir,
25
+ },
26
+ });
27
+ if (headless)
28
+ process.stdout.write(result.stdout);
29
+ return result.code;
30
+ }
31
+ export async function handoff(store, target, cwd) {
32
+ const adapter = target ? agentAdapter(target) : undefined;
33
+ return store.locked(() => {
34
+ const [file, data] = store.current(cwd);
35
+ return ([
36
+ adapter
37
+ ? `Handoff to ${adapter.name}. Start with: rl agent start ${adapter.name}`
38
+ : "RL agent handoff",
39
+ "Shared task notes follow. Agent conversations remain in their original sessions.",
40
+ contextText(file, data),
41
+ ].join("\n\n") + "\n");
42
+ });
43
+ }
44
+ export async function main(argv) {
45
+ const separator = argv.indexOf("--");
46
+ const extra = separator < 0 ? [] : argv.slice(separator + 1);
47
+ const { values, positionals } = args(separator < 0 ? argv : argv.slice(0, separator), [...contextOptions, "prompt", "workspace", "output"], ["stdout"]);
48
+ const context = commandContext(values);
49
+ const target = resolveWorkspace(context, typeof values.workspace === "string" ? values.workspace : undefined);
50
+ const [command, name, ...rest] = positionals;
51
+ arity(rest, 0);
52
+ if (command === "handoff") {
53
+ if (extra.length ||
54
+ values.prompt !== undefined ||
55
+ (values.stdout && values.output !== undefined))
56
+ throw new Error("Usage: rl handoff [agent] [--stdout | --output path]");
57
+ const text = await handoff(context.store, name, target.path);
58
+ if (typeof values.output === "string")
59
+ fs.writeFileSync(values.output, text, { flag: "wx", mode: 0o600 });
60
+ else
61
+ process.stdout.write(text);
62
+ return 0;
63
+ }
64
+ if ((command !== "start" && command !== "run") ||
65
+ !name ||
66
+ values.output !== undefined ||
67
+ values.stdout)
68
+ throw new Error("Usage: rl agent start <codex|claude> [-- args] | run <agent> --prompt text [-- args]");
69
+ return launchAgent(context.store, name, extra, {
70
+ mode: command,
71
+ prompt: typeof values.prompt === "string" ? values.prompt : undefined,
72
+ cwd: target.path,
73
+ });
74
+ }
@@ -0,0 +1,70 @@
1
+ export const PRESET = {
2
+ defaultWorkflow: "writer-reviewer-dry",
3
+ agents: {
4
+ writer: {
5
+ provider: "inherit",
6
+ reasoning: "high",
7
+ access: "workspace-write",
8
+ description: "Implement scoped changes and submit review checkpoints",
9
+ instructions: "Own implementation in the assigned workspace and files. You are not alone: preserve other people's work and coordinate changes with the parent. Start alongside the reviewer and DRY peer; do not spawn replacement reviewers or searchers. Follow the workflow's configured consultation rules. By default, before adding any new function consult the existing DRY peer using event new_function and a precise subject such as src/file.ts#functionName, then wait for its answer and reuse existing behavior when appropriate. Submit bounded, stable file groups to the reviewer with all newFunctions declared. Freeze submitted files while a review is pending; you may continue on unrelated files. Address feedback and resubmit until the configured rating and policy requirements pass. Report actual checks, remaining work and limitations honestly. Do not close your stage until current changes have accepted reviews and your consultations are answered.",
10
+ },
11
+ reviewer: {
12
+ provider: "inherit",
13
+ reasoning: "high",
14
+ access: "read-only",
15
+ description: "Review writer submissions and return evidence-backed ratings",
16
+ instructions: "Stay read-only and available alongside the writer and DRY peer. Review each writer submission against its saved checkpoint, surrounding source, intended behavior and the separate policy. Assess correctness, authorization, persistence, concurrency, type contracts and meaningful validation. Return a rating and rationale using the configured rating scale, with structured findings, coverage limitations and required fixes. Follow the workflow's configured consultation rules. By default, if you suspect duplication consult the existing DRY peer using event suspected_duplicate and a precise subject before returning the review. Declare every duplicateSuspicions subject. Incorporate its evidence rather than treating similarity as proof. Send actionable feedback to the writer, re-review resubmissions, and never equate missing evidence with a clean result. Do not edit code, silently replace peers, or finish before the writer and all submitted work are settled.",
17
+ },
18
+ dry: {
19
+ provider: "inherit",
20
+ reasoning: "high",
21
+ access: "read-only",
22
+ description: "Shared reuse and duplication specialist for writer and reviewer",
23
+ instructions: "Stay read-only and available as the single DRY peer for both writer and reviewer. You may map relevant existing utilities while waiting for requests. Answer new_function consultations from the writer before it adds behavior, and suspected_duplicate consultations from the reviewer before it returns a rating. Search current source for existing functions, components, services, types and dependencies. Cite concrete paths/symbols, behavioral overlap, relevant differences and a viable reuse approach; state explicitly when no suitable implementation was found or coverage is incomplete. Small or superficial similarity alone is not evidence of harmful duplication. Respond to the requesting peer through its consultation record; do not edit files, assign ratings, create more agents, or close while requests or either peer remain active.",
24
+ },
25
+ },
26
+ policies: {
27
+ review: {
28
+ instructions: "Report evidence-backed defects caused or materially worsened by the submitted work. Separate required fixes from optional improvements, and explain prerequisites, expected behavior and observed or inferred failures. Evaluate reuse against concrete alternatives. Avoid style-only blockers and invented findings. Missing checks, dependencies and unverified material paths are coverage limitations. Review ratings are judgments supported by evidence; RL's policy score is calculated separately and is not a probability of correctness.",
29
+ weights: { critical: 50, high: 20, medium: 10, low: 2 },
30
+ minimumScore: 0,
31
+ blockRequired: true,
32
+ rating: {
33
+ min: 0,
34
+ max: 100,
35
+ minimum: 90,
36
+ instructions: "Rate each submission from 0 to 100. 90–100 means ready with verified material behavior and no required fixes; 70–89 needs contained corrections or validation; below 70 needs substantial corrections. Explain the rating and uncertainty. A high rating never overrides required fixes, failed checks or incomplete coverage.",
37
+ },
38
+ },
39
+ },
40
+ workflows: {
41
+ "writer-reviewer-dry": {
42
+ description: "Parallel writer, reviewer and DRY peers with rated review checkpoints",
43
+ policy: "review",
44
+ concurrency: 3,
45
+ retries: 1,
46
+ timeoutSeconds: 3600,
47
+ triggers: [{ event: "task_planned" }, { event: "before_edit" }],
48
+ steps: [
49
+ { id: "writer", agent: "writer", after: [] },
50
+ { id: "reviewer", agent: "reviewer", after: [] },
51
+ { id: "dry", agent: "dry", after: [] },
52
+ ],
53
+ collaboration: {
54
+ writer: "writer",
55
+ reviewer: "reviewer",
56
+ dry: "dry",
57
+ maxRounds: 5,
58
+ rules: [
59
+ { from: "writer", event: "new_function", to: "dry", required: true },
60
+ {
61
+ from: "reviewer",
62
+ event: "suspected_duplicate",
63
+ to: "dry",
64
+ required: true,
65
+ },
66
+ ],
67
+ },
68
+ },
69
+ },
70
+ };
@@ -0,0 +1,8 @@
1
+ import { codex } from "./agent-codex.js";
2
+ import { claude } from "./agent-claude.js";
3
+ export const adapters = { codex, claude };
4
+ export function agentAdapter(name) {
5
+ if (name !== "codex" && name !== "claude")
6
+ throw new Error("Agent must be codex or claude");
7
+ return adapters[name];
8
+ }
@@ -0,0 +1,96 @@
1
+ import { agentAdapter } from "./agent-registry.js";
2
+ import { syncCodexSessions } from "./codex-sessions.js";
3
+ import { execute, message, now, sessionId, timestamp } from "./common.js";
4
+ import { readWorkflowConfig, workflowEnvironment } from "./workflow-config.js";
5
+ import { stripVTControlCharacters } from "node:util";
6
+ export async function resumeSessions(store, kind, options = {}) {
7
+ if (kind !== "claude")
8
+ await syncCodexSessions(store);
9
+ const [, data] = await store.locked(() => store.current());
10
+ const kinds = kind ? [kind] : ["codex", "claude"];
11
+ const choices = kinds
12
+ .flatMap((agent) => data.sessions[agent].map((session) => ({ ...session, agent })))
13
+ .sort((a, b) => {
14
+ const recent = timestamp(b.lastUsedAt) - timestamp(a.lastUsedAt);
15
+ return recent > 0n
16
+ ? 1
17
+ : recent < 0n
18
+ ? -1
19
+ : a.agent.localeCompare(b.agent) || a.id.localeCompare(b.id);
20
+ });
21
+ if (!choices.length)
22
+ throw new Error(`No ${kind ? `${kind} ` : ""}session recorded for ${data.id}. Use 'rl session save' to save a session first`);
23
+ let selected = choices[0];
24
+ if (choices.length > 1 && !options.latest) {
25
+ const visible = (value) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f-\x9f]/g, " ");
26
+ const lines = choices.map((choice) => `${choice.agent}:${choice.id}\t${choice.agent}\t${choice.id}\tLast used: ${visible(choice.lastUsedAt)}\tStarted: ${visible(choice.createdAt)}`);
27
+ let result;
28
+ try {
29
+ result = await execute([
30
+ "fzf",
31
+ "--delimiter=\t",
32
+ "--with-nth=2..",
33
+ "--no-multi",
34
+ "--no-sort",
35
+ "--height=70%",
36
+ "--border=rounded",
37
+ "--reverse",
38
+ "--prompt=Resume session › ",
39
+ `--header=${visible(data.id)} · Agent / session UUID / last used / started\nMost recently used first · Enter resume · Esc cancel`,
40
+ ], data.worktree, { input: lines.join("\n") + "\n", terminalError: true });
41
+ }
42
+ catch (error) {
43
+ throw new Error(`Cannot open session selector: ${message(error)}. Install fzf to choose a saved session`);
44
+ }
45
+ if ([1, 130].includes(result.code))
46
+ return 0;
47
+ if (result.code)
48
+ throw new Error(`Session selector failed (exit ${result.code})`);
49
+ const index = lines.indexOf(result.stdout.replace(/\r?\n$/, ""));
50
+ if (index < 0)
51
+ throw new Error("Session selector returned an unknown selection");
52
+ selected = choices[index];
53
+ }
54
+ return resumeAgent(store, selected.agent, {
55
+ cwd: data.worktree,
56
+ sessionId: selected.id,
57
+ expectedKey: data.key,
58
+ });
59
+ }
60
+ export async function resumeAgent(store, kind, options = {}) {
61
+ const cwd = options.cwd ?? process.cwd();
62
+ if (kind === "codex" && options.sessionId === undefined)
63
+ await syncCodexSessions(store, cwd);
64
+ const [file, data, sid] = await store.locked(() => {
65
+ const [file, data] = store.current(cwd);
66
+ if (options.expectedKey && data.key !== options.expectedKey)
67
+ throw new Error("Instance changed before agent launch");
68
+ const sessions = data.sessions[kind];
69
+ const item = options.sessionId !== undefined
70
+ ? sessions.find((item) => item.id === sessionId(options.sessionId))
71
+ : sessions.reduce((a, b) => !a || timestamp(b.lastUsedAt) > timestamp(a.lastUsedAt) ? b : a, undefined);
72
+ if (!item)
73
+ throw new Error(`No ${kind} session recorded for ${data.id}. Use 'rl session save ${kind}${kind === "claude" ? " <UUID>" : ""}'`);
74
+ return [file, data, sessionId(item.id)];
75
+ });
76
+ const stamp = now();
77
+ console.error(`Resuming ${kind} session ${sid} for ${data.id}`);
78
+ const command = agentAdapter(kind).resumeArgs(sid);
79
+ if (options.prompt !== undefined)
80
+ command.push(options.prompt);
81
+ const result = await execute(command, data.worktree, {
82
+ interactive: true,
83
+ env: {
84
+ ...workflowEnvironment(store.repo, data.worktree, readWorkflowConfig(store.repo)),
85
+ RL_STATE_DIR: store.stateDir,
86
+ },
87
+ });
88
+ if (result.code === 0)
89
+ await store.locked(() => {
90
+ const [currentFile, current] = store.current(data.worktree);
91
+ if (currentFile !== file)
92
+ throw new Error("Instance changed while the agent was running");
93
+ store.recordSession(file, current, kind, sid, stamp);
94
+ });
95
+ return result.code;
96
+ }