rl-workspaces 0.0.0-stage → 0.5.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.
- package/LICENSE +21 -0
- package/README.md +703 -2
- package/bin/rl +54 -0
- package/config.example.zsh +9 -0
- package/dist/access.js +40 -0
- package/dist/activity.js +90 -0
- package/dist/agent-claude.js +16 -0
- package/dist/agent-codex.js +22 -0
- package/dist/agent-launch.js +74 -0
- package/dist/agent-presets.js +45 -0
- package/dist/agent-registry.js +8 -0
- package/dist/agent-resume.js +96 -0
- package/dist/agent-runner.js +366 -0
- package/dist/agent-workflows.js +231 -0
- package/dist/agents.js +73 -0
- package/dist/automation-config.js +242 -0
- package/dist/base.js +66 -0
- package/dist/cleanup-path.js +33 -0
- package/dist/cli.js +57 -0
- package/dist/codex-sessions.js +188 -0
- package/dist/command-context.js +67 -0
- package/dist/common.js +262 -0
- package/dist/delete.js +113 -0
- package/dist/display.js +134 -0
- package/dist/doctor.js +145 -0
- package/dist/git-safety.js +24 -0
- package/dist/git-workflow.js +183 -0
- package/dist/help.js +726 -0
- package/dist/hooks.js +159 -0
- package/dist/json.js +34 -0
- package/dist/mcp.js +336 -0
- package/dist/picker.js +182 -0
- package/dist/pr-discovery.js +223 -0
- package/dist/pr-update.js +419 -0
- package/dist/pr-workflow.js +161 -0
- package/dist/repos.js +537 -0
- package/dist/review-policy.js +150 -0
- package/dist/session-workflows.js +616 -0
- package/dist/session.js +12 -0
- package/dist/state.js +250 -0
- package/dist/storage.js +366 -0
- package/dist/store.js +379 -0
- package/dist/summary.js +164 -0
- package/dist/task-state.js +65 -0
- package/dist/task.js +192 -0
- package/dist/templates.js +4 -0
- package/dist/workflow-config.js +177 -0
- package/dist/workflow.js +211 -0
- package/lib/rl.zsh +330 -0
- package/node_modules/graceful-fs/LICENSE +15 -0
- package/node_modules/graceful-fs/README.md +143 -0
- package/node_modules/graceful-fs/clone.js +23 -0
- package/node_modules/graceful-fs/graceful-fs.js +448 -0
- package/node_modules/graceful-fs/legacy-streams.js +118 -0
- package/node_modules/graceful-fs/package.json +53 -0
- package/node_modules/graceful-fs/polyfills.js +355 -0
- package/node_modules/proper-lockfile/LICENSE +21 -0
- package/node_modules/proper-lockfile/README.md +183 -0
- package/node_modules/proper-lockfile/index.js +40 -0
- package/node_modules/proper-lockfile/lib/adapter.js +85 -0
- package/node_modules/proper-lockfile/lib/lockfile.js +342 -0
- package/node_modules/proper-lockfile/lib/mtime-precision.js +55 -0
- package/node_modules/proper-lockfile/package.json +71 -0
- package/node_modules/retry/.npmignore +3 -0
- package/node_modules/retry/.travis.yml +15 -0
- package/node_modules/retry/License +21 -0
- package/node_modules/retry/Makefile +18 -0
- package/node_modules/retry/README.md +227 -0
- package/node_modules/retry/equation.gif +0 -0
- package/node_modules/retry/example/dns.js +31 -0
- package/node_modules/retry/example/stop.js +40 -0
- package/node_modules/retry/index.js +1 -0
- package/node_modules/retry/lib/retry.js +100 -0
- package/node_modules/retry/lib/retry_operation.js +158 -0
- package/node_modules/retry/package.json +32 -0
- package/node_modules/retry/test/common.js +10 -0
- package/node_modules/retry/test/integration/test-forever.js +24 -0
- package/node_modules/retry/test/integration/test-retry-operation.js +258 -0
- package/node_modules/retry/test/integration/test-retry-wrap.js +101 -0
- package/node_modules/retry/test/integration/test-timeouts.js +69 -0
- package/node_modules/signal-exit/LICENSE.txt +16 -0
- package/node_modules/signal-exit/README.md +39 -0
- package/node_modules/signal-exit/index.js +202 -0
- package/node_modules/signal-exit/package.json +38 -0
- package/node_modules/signal-exit/signals.js +53 -0
- package/package.json +57 -4
- package/shell/rl.zsh +11 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { object, writeJson } from "./common.js";
|
|
4
|
+
import { readJsonObject } from "./json.js";
|
|
5
|
+
import { PRESET } from "./agent-presets.js";
|
|
6
|
+
export const CONFIG_FILES = {
|
|
7
|
+
agents: ".rl/agents.json",
|
|
8
|
+
policies: ".rl/policies.json",
|
|
9
|
+
workflows: ".rl/agent-workflows.json",
|
|
10
|
+
};
|
|
11
|
+
export const SEVERITIES = ["critical", "high", "medium", "low"];
|
|
12
|
+
const ID = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
13
|
+
export function identifier(value) {
|
|
14
|
+
if (typeof value !== "string" ||
|
|
15
|
+
!ID.test(value) ||
|
|
16
|
+
value === "constructor" ||
|
|
17
|
+
value.startsWith("followup-"))
|
|
18
|
+
throw new Error("Expected an identifier using lowercase letters, numbers, hyphens or underscores");
|
|
19
|
+
}
|
|
20
|
+
export function fields(value, allowed, required = allowed) {
|
|
21
|
+
if (!object(value) ||
|
|
22
|
+
Object.keys(value).some((key) => !allowed.includes(key)) ||
|
|
23
|
+
required.some((key) => !(key in value)))
|
|
24
|
+
throw new Error(`Expected fields: ${allowed.join(", ")}`);
|
|
25
|
+
}
|
|
26
|
+
function nonempty(value) {
|
|
27
|
+
if (typeof value !== "string" ||
|
|
28
|
+
!value.trim() ||
|
|
29
|
+
value.includes("\0") ||
|
|
30
|
+
value.length > 64000)
|
|
31
|
+
throw new Error("Expected nonempty text (up to 64000 characters)");
|
|
32
|
+
}
|
|
33
|
+
function integer(value, min, max) {
|
|
34
|
+
if (!Number.isInteger(value) || Number(value) < min || Number(value) > max)
|
|
35
|
+
throw new Error(`Expected an integer from ${min} to ${max}`);
|
|
36
|
+
}
|
|
37
|
+
export function validateAutomation(value) {
|
|
38
|
+
fields(value, ["agents", "policies", "workflows"]);
|
|
39
|
+
for (const kind of ["agents", "policies", "workflows"]) {
|
|
40
|
+
const entries = value[kind];
|
|
41
|
+
if (!object(entries) ||
|
|
42
|
+
!Object.keys(entries).length ||
|
|
43
|
+
Object.keys(entries).length > 64)
|
|
44
|
+
throw new Error(`Expected 1–64 ${kind}`);
|
|
45
|
+
for (const [name, item] of Object.entries(entries)) {
|
|
46
|
+
identifier(name);
|
|
47
|
+
if (kind === "agents") {
|
|
48
|
+
fields(item, [
|
|
49
|
+
"provider",
|
|
50
|
+
"description",
|
|
51
|
+
"instructions",
|
|
52
|
+
"model",
|
|
53
|
+
"reasoning",
|
|
54
|
+
"access",
|
|
55
|
+
], ["description", "instructions"]);
|
|
56
|
+
if (item.provider !== undefined &&
|
|
57
|
+
!["inherit", "codex", "claude"].includes(String(item.provider)))
|
|
58
|
+
throw new Error("Agent provider must be inherit, codex or claude");
|
|
59
|
+
nonempty(item.description);
|
|
60
|
+
nonempty(item.instructions);
|
|
61
|
+
if (item.access !== undefined &&
|
|
62
|
+
item.access !== "read-only" &&
|
|
63
|
+
item.access !== "workspace-write")
|
|
64
|
+
throw new Error("Agent access must be read-only or workspace-write");
|
|
65
|
+
if (item.model !== undefined) {
|
|
66
|
+
nonempty(item.model);
|
|
67
|
+
if (item.model.startsWith("-"))
|
|
68
|
+
throw new Error("Invalid model");
|
|
69
|
+
}
|
|
70
|
+
if (item.reasoning !== undefined &&
|
|
71
|
+
!(item.provider !== "claude"
|
|
72
|
+
? ["low", "medium", "high", "xhigh"]
|
|
73
|
+
: ["low", "medium", "high", "max"]).includes(String(item.reasoning)))
|
|
74
|
+
throw new Error("Unsupported reasoning setting for this provider");
|
|
75
|
+
}
|
|
76
|
+
else if (kind === "policies") {
|
|
77
|
+
fields(item, [
|
|
78
|
+
"instructions",
|
|
79
|
+
"weights",
|
|
80
|
+
"minimumScore",
|
|
81
|
+
"blockRequired",
|
|
82
|
+
]);
|
|
83
|
+
nonempty(item.instructions);
|
|
84
|
+
fields(item.weights, [...SEVERITIES]);
|
|
85
|
+
for (const weight of Object.values(item.weights))
|
|
86
|
+
integer(weight, 0, 100);
|
|
87
|
+
integer(item.minimumScore, 0, 100);
|
|
88
|
+
if (typeof item.blockRequired !== "boolean")
|
|
89
|
+
throw new Error("blockRequired must be boolean");
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
fields(item, [
|
|
93
|
+
"description",
|
|
94
|
+
"policy",
|
|
95
|
+
"concurrency",
|
|
96
|
+
"retries",
|
|
97
|
+
"timeoutSeconds",
|
|
98
|
+
"steps",
|
|
99
|
+
"triggers",
|
|
100
|
+
], [
|
|
101
|
+
"description",
|
|
102
|
+
"policy",
|
|
103
|
+
"concurrency",
|
|
104
|
+
"retries",
|
|
105
|
+
"timeoutSeconds",
|
|
106
|
+
"steps",
|
|
107
|
+
]);
|
|
108
|
+
if (item.triggers !== undefined) {
|
|
109
|
+
if (!Array.isArray(item.triggers) || item.triggers.length > 32)
|
|
110
|
+
throw new Error("Expected up to 32 workflow triggers");
|
|
111
|
+
for (const trigger of item.triggers) {
|
|
112
|
+
fields(trigger, ["event", "tools", "paths", "changes"], ["event"]);
|
|
113
|
+
nonempty(trigger.event);
|
|
114
|
+
if (!/^[a-z][a-z0-9_.-]{0,63}$/.test(trigger.event))
|
|
115
|
+
throw new Error("Invalid trigger event");
|
|
116
|
+
for (const key of ["tools", "paths"])
|
|
117
|
+
if (trigger[key] !== undefined &&
|
|
118
|
+
(!Array.isArray(trigger[key]) ||
|
|
119
|
+
!trigger[key].length ||
|
|
120
|
+
trigger[key].length > 64 ||
|
|
121
|
+
trigger[key].some((text) => typeof text !== "string" ||
|
|
122
|
+
!text ||
|
|
123
|
+
text.length > 512 ||
|
|
124
|
+
/[\x00-\x1f]/.test(text))))
|
|
125
|
+
throw new Error(`Invalid trigger ${key}`);
|
|
126
|
+
if (trigger.changes !== undefined &&
|
|
127
|
+
typeof trigger.changes !== "boolean")
|
|
128
|
+
throw new Error("Trigger changes must be boolean");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
nonempty(item.description);
|
|
132
|
+
identifier(item.policy);
|
|
133
|
+
if (!object(value.policies) ||
|
|
134
|
+
!Object.hasOwn(value.policies, item.policy))
|
|
135
|
+
throw new Error(`Unknown policy: ${item.policy}`);
|
|
136
|
+
integer(item.concurrency, 1, 8);
|
|
137
|
+
integer(item.retries, 0, 3);
|
|
138
|
+
integer(item.timeoutSeconds, 1, 3600);
|
|
139
|
+
if (!Array.isArray(item.steps) ||
|
|
140
|
+
!item.steps.length ||
|
|
141
|
+
item.steps.length > 32)
|
|
142
|
+
throw new Error("Expected 1–32 workflow steps");
|
|
143
|
+
const ids = new Set();
|
|
144
|
+
for (const step of item.steps) {
|
|
145
|
+
fields(step, ["id", "after", "agent", "command"], ["id", "after"]);
|
|
146
|
+
identifier(step.id);
|
|
147
|
+
if (ids.has(step.id))
|
|
148
|
+
throw new Error(`Duplicate step: ${step.id}`);
|
|
149
|
+
ids.add(step.id);
|
|
150
|
+
if (!Array.isArray(step.after) ||
|
|
151
|
+
step.after.some((dep) => typeof dep !== "string") ||
|
|
152
|
+
new Set(step.after).size !== step.after.length)
|
|
153
|
+
throw new Error("Step after must contain unique step names");
|
|
154
|
+
if ((step.agent !== undefined) === (step.command !== undefined))
|
|
155
|
+
throw new Error("Each step requires exactly one agent or command");
|
|
156
|
+
if (step.agent !== undefined &&
|
|
157
|
+
(!object(value.agents) ||
|
|
158
|
+
typeof step.agent !== "string" ||
|
|
159
|
+
!Object.hasOwn(value.agents, step.agent)))
|
|
160
|
+
throw new Error(`Unknown agent: ${step.agent}`);
|
|
161
|
+
if (step.command !== undefined &&
|
|
162
|
+
(!Array.isArray(step.command) ||
|
|
163
|
+
!step.command.length ||
|
|
164
|
+
step.command.some((arg) => typeof arg !== "string" || arg.includes("\0")) ||
|
|
165
|
+
!step.command[0]))
|
|
166
|
+
throw new Error("Commands must be nonempty argv arrays");
|
|
167
|
+
}
|
|
168
|
+
const remaining = new Map(item.steps.map((step) => [String(step.id), step.after]));
|
|
169
|
+
const done = new Set();
|
|
170
|
+
while (remaining.size) {
|
|
171
|
+
const ready = [...remaining].filter(([, deps]) => deps.every((dep) => done.has(dep)));
|
|
172
|
+
if (!ready.length)
|
|
173
|
+
throw new Error("Workflow dependencies contain a cycle or unknown step");
|
|
174
|
+
for (const [id] of ready) {
|
|
175
|
+
remaining.delete(id);
|
|
176
|
+
done.add(id);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function configPath(root, relative) {
|
|
184
|
+
const directory = path.join(root, ".rl"), file = path.join(root, relative);
|
|
185
|
+
if (fs.lstatSync(directory, { throwIfNoEntry: false })?.isSymbolicLink() ||
|
|
186
|
+
fs.lstatSync(file, { throwIfNoEntry: false })?.isSymbolicLink())
|
|
187
|
+
throw new Error("Agent configuration must not be a symlink");
|
|
188
|
+
return file;
|
|
189
|
+
}
|
|
190
|
+
export function readAutomation(root) {
|
|
191
|
+
const result = {};
|
|
192
|
+
for (const [kind, relative] of Object.entries(CONFIG_FILES)) {
|
|
193
|
+
const file = configPath(root, relative);
|
|
194
|
+
if (!fs.existsSync(file))
|
|
195
|
+
result[kind] = structuredClone(PRESET[kind]);
|
|
196
|
+
else {
|
|
197
|
+
const data = readJsonObject(file);
|
|
198
|
+
fields(data, ["version", kind]);
|
|
199
|
+
if (data.version !== 1)
|
|
200
|
+
throw new Error("Unsupported agent configuration version");
|
|
201
|
+
result[kind] = data[kind];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
validateAutomation(result);
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
export function initAutomation(root, provider) {
|
|
208
|
+
const paths = Object.values(CONFIG_FILES).map((file) => configPath(root, file));
|
|
209
|
+
if (paths.some((file) => fs.existsSync(file)))
|
|
210
|
+
throw new Error("Agent configuration already exists; edit the existing files");
|
|
211
|
+
const preset = structuredClone(PRESET);
|
|
212
|
+
for (const profile of Object.values(preset.agents)) {
|
|
213
|
+
profile.provider = provider;
|
|
214
|
+
if (provider === "claude")
|
|
215
|
+
profile.reasoning = "high";
|
|
216
|
+
}
|
|
217
|
+
for (const [kind, relative] of Object.entries(CONFIG_FILES))
|
|
218
|
+
writeJson(configPath(root, relative), {
|
|
219
|
+
version: 1,
|
|
220
|
+
[kind]: preset[kind],
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
export function hasAutomation(root) {
|
|
224
|
+
return Object.values(CONFIG_FILES).some((relative) => fs.existsSync(configPath(root, relative)));
|
|
225
|
+
}
|
|
226
|
+
export function setAutomation(root, kind, name, value, addOnly = false) {
|
|
227
|
+
identifier(name);
|
|
228
|
+
const config = readAutomation(root);
|
|
229
|
+
if (addOnly && Object.hasOwn(config[kind], name))
|
|
230
|
+
throw new Error(`${name} already exists; use set to update it`);
|
|
231
|
+
Object.defineProperty(config[kind], name, {
|
|
232
|
+
value,
|
|
233
|
+
enumerable: true,
|
|
234
|
+
writable: true,
|
|
235
|
+
configurable: true,
|
|
236
|
+
});
|
|
237
|
+
validateAutomation(config);
|
|
238
|
+
writeJson(configPath(root, CONFIG_FILES[kind]), {
|
|
239
|
+
version: 1,
|
|
240
|
+
[kind]: config[kind],
|
|
241
|
+
});
|
|
242
|
+
}
|
package/dist/base.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { git } from "./common.js";
|
|
2
|
+
/** Resolve cached refs only; never fetch or infer a base from a feature's upstream. */
|
|
3
|
+
export function resolveBase(repo, requested) {
|
|
4
|
+
const refs = [
|
|
5
|
+
requested,
|
|
6
|
+
requested.startsWith("origin/")
|
|
7
|
+
? requested.slice(7)
|
|
8
|
+
: "origin/" + requested,
|
|
9
|
+
];
|
|
10
|
+
for (const ref of refs) {
|
|
11
|
+
try {
|
|
12
|
+
return {
|
|
13
|
+
ref,
|
|
14
|
+
oid: git(repo, "rev-parse", "--verify", "--end-of-options", ref + "^{commit}"),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
/* Try the corresponding local or remote ref. */
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
export function repositoryBase(repo, configured) {
|
|
24
|
+
const configuredBase = resolveBase(repo, configured);
|
|
25
|
+
if (configuredBase)
|
|
26
|
+
return configuredBase;
|
|
27
|
+
try {
|
|
28
|
+
const head = git(repo, "symbolic-ref", "--short", "refs/remotes/origin/HEAD");
|
|
29
|
+
const resolved = resolveBase(repo, head);
|
|
30
|
+
if (resolved)
|
|
31
|
+
return resolved;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
/* A local clone may not have origin/HEAD. */
|
|
35
|
+
}
|
|
36
|
+
for (const ref of ["origin/main", "origin/master", "origin/develop"]) {
|
|
37
|
+
const resolved = resolveBase(repo, ref);
|
|
38
|
+
if (resolved)
|
|
39
|
+
return resolved;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
/** A PR target is a branch, not a tag or the feature branch's upstream. */
|
|
44
|
+
export function pullRequestBase(repo, pr) {
|
|
45
|
+
if (typeof pr?.baseRefName !== "string" || !pr.baseRefName)
|
|
46
|
+
return null;
|
|
47
|
+
const target = pr.baseRefName;
|
|
48
|
+
for (const [ref, fullRef] of [
|
|
49
|
+
[`origin/${target}`, `refs/remotes/origin/${target}`],
|
|
50
|
+
[target, `refs/heads/${target}`],
|
|
51
|
+
]) {
|
|
52
|
+
try {
|
|
53
|
+
return {
|
|
54
|
+
target,
|
|
55
|
+
resolved: {
|
|
56
|
+
ref,
|
|
57
|
+
oid: git(repo, "rev-parse", "--verify", "--end-of-options", `${fullRef}^{commit}`),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* The local target branch can exist without a cached remote ref. */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { target, resolved: null };
|
|
66
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
/** Metadata, credentials, and task instructions are never cleanup targets. */
|
|
3
|
+
export function protectedCleanupName(name) {
|
|
4
|
+
return ([
|
|
5
|
+
".git",
|
|
6
|
+
".rl",
|
|
7
|
+
".codex",
|
|
8
|
+
".claude",
|
|
9
|
+
".ssh",
|
|
10
|
+
".aws",
|
|
11
|
+
".azure",
|
|
12
|
+
".config",
|
|
13
|
+
".npmrc",
|
|
14
|
+
".yarnrc",
|
|
15
|
+
".yarnrc.yml",
|
|
16
|
+
".pypirc",
|
|
17
|
+
".netrc",
|
|
18
|
+
"agents.md",
|
|
19
|
+
"claude.md",
|
|
20
|
+
].includes(name.toLowerCase()) ||
|
|
21
|
+
/^(?:\.env(?:\.|$)|credentials(?:\.|$)|secrets(?:\.|$)|\.rl-clean-)/i.test(name));
|
|
22
|
+
}
|
|
23
|
+
export function validateCleanupPath(value) {
|
|
24
|
+
if (typeof value !== "string" ||
|
|
25
|
+
!value ||
|
|
26
|
+
value.trim() !== value ||
|
|
27
|
+
path.isAbsolute(value) ||
|
|
28
|
+
/[\x00-\x1f\x7f\\*?\[\]{}]/.test(value) ||
|
|
29
|
+
value
|
|
30
|
+
.split("/")
|
|
31
|
+
.some((part) => !part || part === "." || part === ".." || protectedCleanupName(part)))
|
|
32
|
+
throw new Error(`Unsafe cleanup path: ${String(value)}`);
|
|
33
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { message } from "./common.js";
|
|
2
|
+
import { invocation, topicHelp, usageHint } from "./help.js";
|
|
3
|
+
import { main as state } from "./state.js";
|
|
4
|
+
import { main as repos } from "./repos.js";
|
|
5
|
+
import { main as remove } from "./delete.js";
|
|
6
|
+
import { main as summary } from "./summary.js";
|
|
7
|
+
import { main as picker } from "./picker.js";
|
|
8
|
+
import { main as workflow } from "./workflow.js";
|
|
9
|
+
import { main as git } from "./git-workflow.js";
|
|
10
|
+
import { main as pr } from "./pr-workflow.js";
|
|
11
|
+
import { main as agent } from "./agent-launch.js";
|
|
12
|
+
import { main as mcp } from "./mcp.js";
|
|
13
|
+
import { main as task } from "./task.js";
|
|
14
|
+
import { main as storage } from "./storage.js";
|
|
15
|
+
import { main as prUpdate } from "./pr-update.js";
|
|
16
|
+
import { main as agentWorkflows } from "./agent-workflows.js";
|
|
17
|
+
const commands = {
|
|
18
|
+
state,
|
|
19
|
+
repos,
|
|
20
|
+
delete: remove,
|
|
21
|
+
summary,
|
|
22
|
+
picker,
|
|
23
|
+
workflow,
|
|
24
|
+
git,
|
|
25
|
+
pr,
|
|
26
|
+
agent,
|
|
27
|
+
mcp,
|
|
28
|
+
task,
|
|
29
|
+
storage,
|
|
30
|
+
"pr-update": prUpdate,
|
|
31
|
+
automation: agentWorkflows,
|
|
32
|
+
};
|
|
33
|
+
try {
|
|
34
|
+
const [command, ...argv] = process.argv.slice(2);
|
|
35
|
+
if (!Object.hasOwn(commands, command))
|
|
36
|
+
throw new Error("Unknown internal command");
|
|
37
|
+
const separator = argv.indexOf("--");
|
|
38
|
+
const options = separator < 0 ? argv : argv.slice(0, separator);
|
|
39
|
+
if (options.includes("--help") || options.includes("-h")) {
|
|
40
|
+
console.log(topicHelp(invocation(command, options.filter((arg) => arg !== "--help" && arg !== "-h"))));
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
process.exitCode = await commands[command](argv);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
const detail = message(error).replace("; run rl --help", "");
|
|
48
|
+
console.error(`rl: ${detail.startsWith("Usage:") ? "Invalid command arguments" : detail}`);
|
|
49
|
+
const parseError = typeof error === "object" &&
|
|
50
|
+
error !== null &&
|
|
51
|
+
"code" in error &&
|
|
52
|
+
String(error.code).startsWith("ERR_PARSE_ARGS_");
|
|
53
|
+
const argumentsError = parseError ||
|
|
54
|
+
/^(?:Usage:|Invalid command arguments|Unknown option|Missing --|Expected|Unsupported .*option|--.*(?:require|supported|accept))/.test(detail);
|
|
55
|
+
console.error(usageHint(process.argv[2] ?? "", process.argv.slice(3), argumentsError));
|
|
56
|
+
process.exitCode = 1;
|
|
57
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { canonical, object, sessionId, timestamp } from "./common.js";
|
|
4
|
+
const SOURCES = ["cli", "vscode", "exec", "appServer"];
|
|
5
|
+
// Ask Codex for workspace-filtered metadata only. Never read transcripts or
|
|
6
|
+
// request a model turn, and never fall back to an unfiltered history query.
|
|
7
|
+
async function discover(cwd, roots) {
|
|
8
|
+
const child = spawn("codex", ["app-server"], {
|
|
9
|
+
cwd,
|
|
10
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
11
|
+
});
|
|
12
|
+
const lines = createInterface({ input: child.stdout });
|
|
13
|
+
let requestId = 0;
|
|
14
|
+
let pending;
|
|
15
|
+
let failure;
|
|
16
|
+
let bytes = 0;
|
|
17
|
+
const fail = (error) => {
|
|
18
|
+
failure = error;
|
|
19
|
+
pending?.reject(error);
|
|
20
|
+
pending = undefined;
|
|
21
|
+
child.kill();
|
|
22
|
+
};
|
|
23
|
+
const timer = setTimeout(() => fail(new Error("Codex session discovery timed out")), 3000);
|
|
24
|
+
child.on("error", fail);
|
|
25
|
+
child.on("close", () => fail(new Error("Codex session discovery closed")));
|
|
26
|
+
child.stdin.on("error", fail);
|
|
27
|
+
child.stdout.on("data", (chunk) => {
|
|
28
|
+
bytes += chunk.length;
|
|
29
|
+
if (bytes > 8 * 1024 * 1024)
|
|
30
|
+
fail(new Error("Codex session metadata is too large"));
|
|
31
|
+
});
|
|
32
|
+
lines.on("line", (line) => {
|
|
33
|
+
try {
|
|
34
|
+
const reply = JSON.parse(line);
|
|
35
|
+
if (!object(reply) || reply.id !== pending?.id || !pending)
|
|
36
|
+
return;
|
|
37
|
+
if (reply.error !== undefined || !object(reply.result))
|
|
38
|
+
throw new Error("Codex rejected session discovery");
|
|
39
|
+
const request = pending;
|
|
40
|
+
pending = undefined;
|
|
41
|
+
request.resolve(reply.result);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
const request = (method, params) => new Promise((resolve, reject) => {
|
|
48
|
+
if (failure)
|
|
49
|
+
return reject(failure);
|
|
50
|
+
pending = { id: ++requestId, resolve, reject };
|
|
51
|
+
child.stdin.write(JSON.stringify({ id: requestId, method, params }) + "\n");
|
|
52
|
+
});
|
|
53
|
+
try {
|
|
54
|
+
await request("initialize", { clientInfo: { name: "rl", version: "1" } });
|
|
55
|
+
child.stdin.write(JSON.stringify({ method: "initialized" }) + "\n");
|
|
56
|
+
const sessions = [];
|
|
57
|
+
// A string cwd also works with older app-server versions.
|
|
58
|
+
for (const root of roots) {
|
|
59
|
+
let cursor;
|
|
60
|
+
const cursors = new Set();
|
|
61
|
+
do {
|
|
62
|
+
const result = await request("thread/list", {
|
|
63
|
+
cwd: root,
|
|
64
|
+
useStateDbOnly: true,
|
|
65
|
+
sourceKinds: SOURCES,
|
|
66
|
+
limit: 100,
|
|
67
|
+
...(cursor ? { cursor } : {}),
|
|
68
|
+
});
|
|
69
|
+
if (!Array.isArray(result.data))
|
|
70
|
+
throw new Error("Invalid Codex thread list");
|
|
71
|
+
for (const thread of result.data) {
|
|
72
|
+
if (!object(thread) ||
|
|
73
|
+
thread.ephemeral === true ||
|
|
74
|
+
(thread.source !== undefined &&
|
|
75
|
+
(typeof thread.source !== "string" ||
|
|
76
|
+
!SOURCES.includes(thread.source))) ||
|
|
77
|
+
typeof thread.cwd !== "string" ||
|
|
78
|
+
canonical(thread.cwd) !== root)
|
|
79
|
+
continue;
|
|
80
|
+
// Only UUIDs can be passed unambiguously to the installed resume CLI.
|
|
81
|
+
try {
|
|
82
|
+
if (typeof thread.createdAt !== "number" ||
|
|
83
|
+
typeof thread.updatedAt !== "number")
|
|
84
|
+
continue;
|
|
85
|
+
const createdAt = new Date(thread.createdAt * 1000).toISOString();
|
|
86
|
+
const lastUsedAt = new Date(thread.updatedAt * 1000).toISOString();
|
|
87
|
+
sessions.push({
|
|
88
|
+
id: sessionId(thread.id),
|
|
89
|
+
cwd: root,
|
|
90
|
+
createdAt,
|
|
91
|
+
lastUsedAt,
|
|
92
|
+
branch: object(thread.gitInfo) &&
|
|
93
|
+
typeof thread.gitInfo.branch === "string"
|
|
94
|
+
? thread.gitInfo.branch
|
|
95
|
+
: undefined,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
/* Unsupported identifiers or invalid timestamps are not resume targets. */
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (result.nextCursor !== null &&
|
|
103
|
+
result.nextCursor !== undefined &&
|
|
104
|
+
typeof result.nextCursor !== "string")
|
|
105
|
+
throw new Error("Invalid Codex thread cursor");
|
|
106
|
+
cursor = result.nextCursor;
|
|
107
|
+
if (cursor && cursors.has(cursor))
|
|
108
|
+
throw new Error("Repeated Codex thread cursor");
|
|
109
|
+
if (cursor)
|
|
110
|
+
cursors.add(cursor);
|
|
111
|
+
} while (cursor);
|
|
112
|
+
}
|
|
113
|
+
return sessions;
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
lines.close();
|
|
118
|
+
child.stdin.end();
|
|
119
|
+
child.kill();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export async function syncCodexSessions(store, cwd) {
|
|
123
|
+
const records = await store.locked(() => cwd
|
|
124
|
+
? [store.current(cwd)]
|
|
125
|
+
: store.records().filter(([, data]) => data.status === "active"));
|
|
126
|
+
const eligible = records.filter(([, data]) => {
|
|
127
|
+
try {
|
|
128
|
+
return store.current(data.worktree)[1].key === data.key;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
if (!eligible.length)
|
|
135
|
+
return;
|
|
136
|
+
let sessions;
|
|
137
|
+
try {
|
|
138
|
+
sessions = await discover(eligible[0][1].worktree, eligible.map(([, data]) => data.worktree));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Missing/older Codex or a temporarily unavailable server must not hide RL
|
|
142
|
+
// status or erase already saved sessions. Hooks/manual save still work.
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
await store.locked(() => {
|
|
146
|
+
for (const [file, snapshot] of eligible) {
|
|
147
|
+
let current;
|
|
148
|
+
try {
|
|
149
|
+
current = store.current(snapshot.worktree);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const [currentFile, data] = current;
|
|
155
|
+
if (currentFile !== file || data.key !== snapshot.key)
|
|
156
|
+
continue;
|
|
157
|
+
let changed = false;
|
|
158
|
+
for (const session of sessions) {
|
|
159
|
+
if (session.cwd !== data.worktree ||
|
|
160
|
+
(session.branch !== undefined && session.branch !== data.branch) ||
|
|
161
|
+
(session.branch === undefined &&
|
|
162
|
+
timestamp(session.createdAt) < timestamp(data.createdAt)))
|
|
163
|
+
continue;
|
|
164
|
+
try {
|
|
165
|
+
store.assertSessionOwner(file, "codex", session.id);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const saved = data.sessions.codex.find((item) => item.id === session.id);
|
|
171
|
+
if (!saved) {
|
|
172
|
+
data.sessions.codex.push({
|
|
173
|
+
id: session.id,
|
|
174
|
+
createdAt: session.createdAt,
|
|
175
|
+
lastUsedAt: session.lastUsedAt,
|
|
176
|
+
});
|
|
177
|
+
changed = true;
|
|
178
|
+
}
|
|
179
|
+
else if (timestamp(session.lastUsedAt) > timestamp(saved.lastUsedAt)) {
|
|
180
|
+
saved.lastUsedAt = session.lastUsedAt;
|
|
181
|
+
changed = true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (changed)
|
|
185
|
+
store.save(file, data);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { canonical, inside, required } from "./common.js";
|
|
5
|
+
import { candidates } from "./picker.js";
|
|
6
|
+
import { Store } from "./store.js";
|
|
7
|
+
export const contextOptions = ["repo", "state-dir", "base-dir", "default-base"];
|
|
8
|
+
export function writeWorkspaceOutput(workspace, output) {
|
|
9
|
+
if (output === undefined) {
|
|
10
|
+
console.log(workspace);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const target = path.resolve(output);
|
|
14
|
+
if (!inside(canonical(target), canonical(os.tmpdir())))
|
|
15
|
+
throw new Error("Shell output file must be inside the temporary directory");
|
|
16
|
+
const fd = fs.openSync(target, fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW);
|
|
17
|
+
try {
|
|
18
|
+
const stat = fs.fstatSync(fd);
|
|
19
|
+
if (!stat.isFile() || stat.size !== 0 || stat.nlink !== 1)
|
|
20
|
+
throw new Error("Shell output file must be an empty regular file");
|
|
21
|
+
fs.writeFileSync(fd, workspace);
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
fs.closeSync(fd);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Settings have already been selected by the public CLI's repository router. */
|
|
28
|
+
export function commandContext(values) {
|
|
29
|
+
const repo = required(values, "repo"), stateDir = required(values, "state-dir"), managedDir = required(values, "base-dir");
|
|
30
|
+
if (![repo, stateDir, managedDir].every((value) => path.isAbsolute(value)))
|
|
31
|
+
throw new Error("Repository, state and managed workspace paths must be absolute");
|
|
32
|
+
const store = new Store(repo, stateDir);
|
|
33
|
+
return {
|
|
34
|
+
repo: store.repo,
|
|
35
|
+
stateDir: store.stateDir,
|
|
36
|
+
managedDir: canonical(managedDir),
|
|
37
|
+
defaultBase: String(values["default-base"] ?? "origin/test"),
|
|
38
|
+
store,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Resolve only the current checkout or registered managed worktrees, never scan. */
|
|
42
|
+
export function resolveWorkspace(context, selector) {
|
|
43
|
+
if (!selector) {
|
|
44
|
+
const [root, branch] = context.store.worktree(process.cwd());
|
|
45
|
+
return { path: root, name: path.basename(root), branch };
|
|
46
|
+
}
|
|
47
|
+
if (selector === "@" ||
|
|
48
|
+
(path.isAbsolute(selector) && canonical(selector) === context.repo)) {
|
|
49
|
+
const [root, branch] = context.store.worktree(context.repo);
|
|
50
|
+
return { path: root, name: "@", branch };
|
|
51
|
+
}
|
|
52
|
+
const choices = candidates(context.repo, context.managedDir);
|
|
53
|
+
const matches = choices.filter((item) => item.name === selector ||
|
|
54
|
+
item.branch === selector ||
|
|
55
|
+
(path.isAbsolute(selector) && item.path === canonical(selector)));
|
|
56
|
+
if (matches.length !== 1)
|
|
57
|
+
throw new Error(matches.length
|
|
58
|
+
? `Ambiguous workspace: ${selector}; use its managed directory name`
|
|
59
|
+
: `Workspace not found: ${selector}. Use rl list or rl switch --create.`);
|
|
60
|
+
const item = matches[0];
|
|
61
|
+
// Revalidate repository identity and branch. Worktree metadata alone can be
|
|
62
|
+
// stale after the directory has been removed or replaced.
|
|
63
|
+
const [root, branch] = context.store.worktree(item.path);
|
|
64
|
+
if (branch !== item.branch)
|
|
65
|
+
throw new Error("Workspace branch changed during selection; retry the command");
|
|
66
|
+
return { path: root, name: item.name, branch };
|
|
67
|
+
}
|