@flumecode/runner 0.0.1
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/README.md +98 -0
- package/dist/cli.js +820 -0
- package/package.json +38 -0
- package/skills-plugin/.claude-plugin/plugin.json +5 -0
- package/skills-plugin/rules/coding-guideline.md +49 -0
- package/skills-plugin/skills/document/SKILL.md +164 -0
- package/skills-plugin/skills/implement-plan/SKILL.md +118 -0
- package/skills-plugin/skills/request-to-plan/SKILL.md +96 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { stdin, stdout } from "node:process";
|
|
6
|
+
|
|
7
|
+
// src/config.ts
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
var CONFIG_DIR = join(homedir(), ".flume");
|
|
12
|
+
var configPath = join(CONFIG_DIR, "config.json");
|
|
13
|
+
function readConfig() {
|
|
14
|
+
if (!existsSync(configPath)) return null;
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
17
|
+
if (!parsed.serverUrl || !parsed.token) return null;
|
|
18
|
+
return { serverUrl: parsed.serverUrl, token: parsed.token };
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function writeConfig(config) {
|
|
24
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
25
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 384 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// src/version.ts
|
|
29
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
function readVersion() {
|
|
32
|
+
try {
|
|
33
|
+
const path = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
34
|
+
const pkg = JSON.parse(readFileSync2(path, "utf8"));
|
|
35
|
+
return pkg.version ?? "unknown";
|
|
36
|
+
} catch {
|
|
37
|
+
return "unknown";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
var RUNNER_VERSION = readVersion();
|
|
41
|
+
var RUNNER_VERSION_HEADER = "x-flumecode-runner-version";
|
|
42
|
+
|
|
43
|
+
// src/api.ts
|
|
44
|
+
async function claimJob(config) {
|
|
45
|
+
const res = await fetch(`${config.serverUrl}/api/runner/jobs/claim`, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: {
|
|
48
|
+
authorization: `Bearer ${config.token}`,
|
|
49
|
+
[RUNNER_VERSION_HEADER]: RUNNER_VERSION
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
if (res.status === 204) return null;
|
|
53
|
+
if (res.status === 401) {
|
|
54
|
+
throw new Error("Runner token rejected (401). Re-run `login` with a fresh token.");
|
|
55
|
+
}
|
|
56
|
+
if (!res.ok) throw new Error(`claim failed: ${res.status} ${await safeText(res)}`);
|
|
57
|
+
return await res.json();
|
|
58
|
+
}
|
|
59
|
+
async function reportJob(config, jobId, result) {
|
|
60
|
+
const res = await fetch(`${config.serverUrl}/api/runner/jobs/${jobId}/complete`, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: {
|
|
63
|
+
authorization: `Bearer ${config.token}`,
|
|
64
|
+
"content-type": "application/json",
|
|
65
|
+
[RUNNER_VERSION_HEADER]: RUNNER_VERSION
|
|
66
|
+
},
|
|
67
|
+
body: JSON.stringify(result)
|
|
68
|
+
});
|
|
69
|
+
if (!res.ok) throw new Error(`complete failed: ${res.status} ${await safeText(res)}`);
|
|
70
|
+
}
|
|
71
|
+
async function reportHeartbeat(config, claudeCode) {
|
|
72
|
+
const res = await fetch(`${config.serverUrl}/api/runner/heartbeat`, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: {
|
|
75
|
+
authorization: `Bearer ${config.token}`,
|
|
76
|
+
"content-type": "application/json",
|
|
77
|
+
[RUNNER_VERSION_HEADER]: RUNNER_VERSION
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify({ claudeCode })
|
|
80
|
+
});
|
|
81
|
+
if (!res.ok) throw new Error(`heartbeat failed: ${res.status} ${await safeText(res)}`);
|
|
82
|
+
}
|
|
83
|
+
async function safeText(res) {
|
|
84
|
+
try {
|
|
85
|
+
return await res.text();
|
|
86
|
+
} catch {
|
|
87
|
+
return "";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/executor.ts
|
|
92
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
93
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
94
|
+
|
|
95
|
+
// src/widgets.ts
|
|
96
|
+
import { randomUUID } from "node:crypto";
|
|
97
|
+
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
98
|
+
import { z } from "zod";
|
|
99
|
+
var SERVER_NAME = "flume_widgets";
|
|
100
|
+
var SINGLE_SELECT = "single_select";
|
|
101
|
+
var MULTI_SELECT = "multi_select";
|
|
102
|
+
var WIDGET_TOOL_NAMES = [
|
|
103
|
+
`mcp__${SERVER_NAME}__${SINGLE_SELECT}`,
|
|
104
|
+
`mcp__${SERVER_NAME}__${MULTI_SELECT}`
|
|
105
|
+
];
|
|
106
|
+
var optionsSchema = z.array(z.string().min(1)).min(2).max(8).describe("2\u20138 short, distinct choices for the user to pick from.");
|
|
107
|
+
var TAIL = "Do NOT add an 'Other' or 'None of these' catch-all \u2014 the UI always offers an 'Other' free-text option automatically. After calling this, END YOUR TURN and wait: the user's answer arrives as their next message and starts a fresh run.";
|
|
108
|
+
function createWidgetTooling() {
|
|
109
|
+
const collected = [];
|
|
110
|
+
const singleSelect = tool(
|
|
111
|
+
SINGLE_SELECT,
|
|
112
|
+
"Ask the user a single-select (radio-button) question \u2014 exactly one answer. Use this for a genuine either/or choice (competing approaches, scope decisions, yes/no) instead of writing the options as prose. " + TAIL,
|
|
113
|
+
{
|
|
114
|
+
question: z.string().min(1).describe("The question to ask the user."),
|
|
115
|
+
options: optionsSchema
|
|
116
|
+
},
|
|
117
|
+
async (args) => {
|
|
118
|
+
collected.push({
|
|
119
|
+
id: randomUUID(),
|
|
120
|
+
type: "single_select",
|
|
121
|
+
question: args.question,
|
|
122
|
+
options: args.options.map((label) => ({ id: randomUUID(), label })),
|
|
123
|
+
selectedOptionId: null,
|
|
124
|
+
customAnswer: null
|
|
125
|
+
});
|
|
126
|
+
return widgetPosted("single-select");
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
const multiSelect = tool(
|
|
130
|
+
MULTI_SELECT,
|
|
131
|
+
"Ask the user a multi-select (checkbox) question \u2014 they may pick any number of options, including none of the presets if they use 'Other'. Use this for 'select all that apply' questions (which features to include, which files to touch). " + TAIL,
|
|
132
|
+
{
|
|
133
|
+
question: z.string().min(1).describe("The question to ask the user."),
|
|
134
|
+
options: optionsSchema
|
|
135
|
+
},
|
|
136
|
+
async (args) => {
|
|
137
|
+
collected.push({
|
|
138
|
+
id: randomUUID(),
|
|
139
|
+
type: "multi_select",
|
|
140
|
+
question: args.question,
|
|
141
|
+
options: args.options.map((label) => ({ id: randomUUID(), label })),
|
|
142
|
+
selectedOptionIds: null,
|
|
143
|
+
customAnswer: null
|
|
144
|
+
});
|
|
145
|
+
return widgetPosted("multi-select");
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
const mcpServer = createSdkMcpServer({
|
|
149
|
+
name: SERVER_NAME,
|
|
150
|
+
tools: [singleSelect, multiSelect]
|
|
151
|
+
});
|
|
152
|
+
return { mcpServer, collected };
|
|
153
|
+
}
|
|
154
|
+
function widgetPosted(kind) {
|
|
155
|
+
return {
|
|
156
|
+
content: [
|
|
157
|
+
{
|
|
158
|
+
type: "text",
|
|
159
|
+
text: `Question posted to the user as a ${kind} widget. End your turn now and wait \u2014 their answer will arrive as their next message.`
|
|
160
|
+
}
|
|
161
|
+
]
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// src/plan.ts
|
|
166
|
+
import { createSdkMcpServer as createSdkMcpServer2, tool as tool2 } from "@anthropic-ai/claude-agent-sdk";
|
|
167
|
+
import { z as z2 } from "zod";
|
|
168
|
+
var SERVER_NAME2 = "flume_plan";
|
|
169
|
+
var SUBMIT_PLAN = "submit_plan";
|
|
170
|
+
var PLAN_TOOL_NAME = `mcp__${SERVER_NAME2}__${SUBMIT_PLAN}`;
|
|
171
|
+
var PLAN_MARKER = "<!-- flumecode:end-of-plan -->";
|
|
172
|
+
var stepSchema = z2.object({
|
|
173
|
+
change: z2.string().min(1).describe("What changes, with concrete file references."),
|
|
174
|
+
why: z2.string().min(1).describe("The reason for this step."),
|
|
175
|
+
files: z2.array(z2.string()).optional().describe("Affected file paths.")
|
|
176
|
+
});
|
|
177
|
+
var planInputSchema = {
|
|
178
|
+
scope: z2.enum(["feat", "fix", "chore", "docs", "test", "refactor"]).describe("The primary intent of the change."),
|
|
179
|
+
goal: z2.string().min(1).describe("One or two sentences stating the outcome."),
|
|
180
|
+
assumptions: z2.array(z2.string()).describe("Anything decided during planning, including unanswered defaults."),
|
|
181
|
+
steps: z2.array(stepSchema).min(1).describe("Ordered list of changes. Each step says what and why, with file references."),
|
|
182
|
+
acceptanceCriteria: z2.array(z2.string().min(1)).min(2).describe(
|
|
183
|
+
"Observable conditions that together define done. Each must be checkable \u2014 a behavior, test, or state you could verify \u2014 not a restatement of a step. At least 2 required."
|
|
184
|
+
),
|
|
185
|
+
risks: z2.array(z2.string()).describe("Anything that could change the approach."),
|
|
186
|
+
outOfScope: z2.array(z2.string()).describe("What is deliberately not being done.")
|
|
187
|
+
};
|
|
188
|
+
var planSchema = z2.object(planInputSchema);
|
|
189
|
+
function renderPlan(plan) {
|
|
190
|
+
const lines = [];
|
|
191
|
+
lines.push(`**Scope** \u2014 \`${plan.scope}\``);
|
|
192
|
+
lines.push("");
|
|
193
|
+
lines.push(`**Goal** \u2014 ${plan.goal}`);
|
|
194
|
+
if (plan.assumptions.length > 0) {
|
|
195
|
+
lines.push("");
|
|
196
|
+
lines.push("**Assumptions**");
|
|
197
|
+
for (const assumption of plan.assumptions) {
|
|
198
|
+
lines.push(`- ${assumption}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
lines.push("");
|
|
202
|
+
lines.push("**Steps**");
|
|
203
|
+
for (const [i, step] of plan.steps.entries()) {
|
|
204
|
+
lines.push(`${i + 1}. **${step.change}** \u2014 ${step.why}`);
|
|
205
|
+
if (step.files && step.files.length > 0) {
|
|
206
|
+
for (const file of step.files) {
|
|
207
|
+
lines.push(` - \`${file}\``);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
lines.push("");
|
|
212
|
+
lines.push("## Acceptance criteria");
|
|
213
|
+
for (const criterion of plan.acceptanceCriteria) {
|
|
214
|
+
lines.push(`- [ ] ${criterion}`);
|
|
215
|
+
}
|
|
216
|
+
if (plan.risks.length > 0) {
|
|
217
|
+
lines.push("");
|
|
218
|
+
lines.push("**Risks / open questions**");
|
|
219
|
+
for (const risk of plan.risks) {
|
|
220
|
+
lines.push(`- ${risk}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (plan.outOfScope.length > 0) {
|
|
224
|
+
lines.push("");
|
|
225
|
+
lines.push("**Out of scope**");
|
|
226
|
+
for (const item of plan.outOfScope) {
|
|
227
|
+
lines.push(`- ${item}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
lines.push("");
|
|
231
|
+
lines.push(PLAN_MARKER);
|
|
232
|
+
return lines.join("\n");
|
|
233
|
+
}
|
|
234
|
+
function createPlanTooling() {
|
|
235
|
+
let renderedPlan = null;
|
|
236
|
+
const submitPlan = tool2(
|
|
237
|
+
SUBMIT_PLAN,
|
|
238
|
+
"Submit the finished plan. Call this \u2014 and only this \u2014 when the plan is complete and ready to post. The runner renders your structured fields into the canonical plan markdown and posts it as your comment. acceptanceCriteria is required and must contain at least 2 observable, verifiable conditions (behaviors, tests, or states you could check) that together define 'done'. After calling this, end your turn.",
|
|
239
|
+
planInputSchema,
|
|
240
|
+
async (args) => {
|
|
241
|
+
renderedPlan = renderPlan(planSchema.parse(args));
|
|
242
|
+
return {
|
|
243
|
+
content: [
|
|
244
|
+
{
|
|
245
|
+
type: "text",
|
|
246
|
+
text: "Plan submitted. The runner will render and post it as your comment. End your turn now."
|
|
247
|
+
}
|
|
248
|
+
]
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
);
|
|
252
|
+
const mcpServer = createSdkMcpServer2({
|
|
253
|
+
name: SERVER_NAME2,
|
|
254
|
+
tools: [submitPlan]
|
|
255
|
+
});
|
|
256
|
+
return { mcpServer, getPlan: () => renderedPlan };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// src/executor.ts
|
|
260
|
+
var FLUME_PLUGIN_DIR = fileURLToPath2(new URL("../skills-plugin", import.meta.url));
|
|
261
|
+
async function runClaudeCode(opts) {
|
|
262
|
+
let finalText = "";
|
|
263
|
+
const { mcpServer, collected } = createWidgetTooling();
|
|
264
|
+
const { mcpServer: planServer, getPlan } = createPlanTooling();
|
|
265
|
+
for await (const message of query({
|
|
266
|
+
prompt: opts.prompt,
|
|
267
|
+
options: {
|
|
268
|
+
cwd: opts.cwd,
|
|
269
|
+
permissionMode: opts.permissionMode,
|
|
270
|
+
allowDangerouslySkipPermissions: opts.permissionMode === "bypassPermissions",
|
|
271
|
+
...opts.model ? { model: opts.model } : {},
|
|
272
|
+
maxTurns: opts.maxTurns ?? 40,
|
|
273
|
+
// Hermetic: ignore ALL config from the checked-out repo (CLAUDE.md,
|
|
274
|
+
// .claude/settings.json, .claude/skills, …) and the runner owner's
|
|
275
|
+
// ~/.claude. Only our plugin's skills are exposed to the agent. Auto-memory
|
|
276
|
+
// is disabled separately via CLAUDE_CODE_DISABLE_AUTO_MEMORY (see cli.ts).
|
|
277
|
+
settingSources: [],
|
|
278
|
+
plugins: [{ type: "local", path: FLUME_PLUGIN_DIR }],
|
|
279
|
+
// Pre-approve the widget, plan, and Task tools so they run without a
|
|
280
|
+
// permission prompt in headless mode (allow-listing pre-approves them; it
|
|
281
|
+
// does NOT restrict anything else). Task lets the implement-plan
|
|
282
|
+
// orchestrator spawn its subagents; without pre-approval the spawn could
|
|
283
|
+
// stall waiting for an approval no one can give.
|
|
284
|
+
mcpServers: { flume_widgets: mcpServer, flume_plan: planServer },
|
|
285
|
+
allowedTools: [...WIDGET_TOOL_NAMES, PLAN_TOOL_NAME, "Task"]
|
|
286
|
+
}
|
|
287
|
+
})) {
|
|
288
|
+
if (message.type === "assistant") {
|
|
289
|
+
const content = message.message?.content;
|
|
290
|
+
if (Array.isArray(content)) {
|
|
291
|
+
for (const block of content) {
|
|
292
|
+
if (block && block.type === "text" && typeof block.text === "string") {
|
|
293
|
+
process.stdout.write(block.text);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
} else if (message.type === "result") {
|
|
298
|
+
finalText = message.result ?? "";
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
process.stdout.write("\n");
|
|
302
|
+
return { text: finalText, widgets: collected, plan: getPlan() };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// src/health.ts
|
|
306
|
+
import { query as query2 } from "@anthropic-ai/claude-agent-sdk";
|
|
307
|
+
var PROBE_TIMEOUT_MS = 6e4;
|
|
308
|
+
async function checkClaudeCode() {
|
|
309
|
+
try {
|
|
310
|
+
let sawResult = false;
|
|
311
|
+
const run = (async () => {
|
|
312
|
+
for await (const message of query2({
|
|
313
|
+
prompt: "Reply with the single word: ok",
|
|
314
|
+
options: { permissionMode: "bypassPermissions", maxTurns: 1 }
|
|
315
|
+
})) {
|
|
316
|
+
if (message.type === "result") {
|
|
317
|
+
sawResult = true;
|
|
318
|
+
const isError = message.is_error === true;
|
|
319
|
+
const subtype = message.subtype;
|
|
320
|
+
if (isError)
|
|
321
|
+
throw new Error(
|
|
322
|
+
subtype ? `Claude Code error: ${subtype}` : "Claude Code returned an error"
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
})();
|
|
327
|
+
await withTimeout(run, PROBE_TIMEOUT_MS, "Claude Code did not respond in time");
|
|
328
|
+
if (!sawResult) return { ready: false, error: "Claude Code produced no result" };
|
|
329
|
+
return { ready: true };
|
|
330
|
+
} catch (err) {
|
|
331
|
+
return { ready: false, error: errorMessage(err) };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function withTimeout(p, ms, message) {
|
|
335
|
+
return new Promise((resolve, reject) => {
|
|
336
|
+
const timer = setTimeout(() => reject(new Error(message)), ms);
|
|
337
|
+
p.then(
|
|
338
|
+
(v) => {
|
|
339
|
+
clearTimeout(timer);
|
|
340
|
+
resolve(v);
|
|
341
|
+
},
|
|
342
|
+
(e) => {
|
|
343
|
+
clearTimeout(timer);
|
|
344
|
+
reject(e);
|
|
345
|
+
}
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
function errorMessage(err) {
|
|
350
|
+
return err instanceof Error ? err.message : String(err);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// src/rules.ts
|
|
354
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
355
|
+
import { join as join2 } from "node:path";
|
|
356
|
+
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
357
|
+
var RULES_DIR = fileURLToPath3(new URL("../skills-plugin/rules", import.meta.url));
|
|
358
|
+
function loadRule(name) {
|
|
359
|
+
const raw = readFileSync3(join2(RULES_DIR, `${name}.md`), "utf8");
|
|
360
|
+
return stripFrontMatter(raw).trim();
|
|
361
|
+
}
|
|
362
|
+
function stripFrontMatter(raw) {
|
|
363
|
+
const match = raw.match(/^---\n.*?\n---\n/s);
|
|
364
|
+
return match ? raw.slice(match[0].length) : raw;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// src/prompt.ts
|
|
368
|
+
function buildPrompt(ctx) {
|
|
369
|
+
const task = ctx.permissionMode === "plan" ? `Use the \`flumecode:request-to-plan\` skill to handle this request. You are read-only and cannot modify files \u2014 clarify any ambiguity with the user first, then produce a concrete, actionable plan (the specific changes you would make and why). Cite the relevant files. Do NOT call ExitPlanMode or write the plan to a file. When the plan is ready, call the \`submit_plan\` tool with the structured plan fields; the runner renders it into the canonical plan markdown and posts it as your comment.` : `Use the \`flumecode:implement-plan\` skill to handle this request. You are the ORCHESTRATOR: do not implement, review, or write the report yourself \u2014 follow the skill to delegate each phase to subagents via the Task tool, picking the right model for each. Do not commit or push \u2014 the runner handles that.`;
|
|
370
|
+
const orient = `Before investigating raw source, check for a FlumeCode wiki at \`.flumecode/wiki/\`. If it exists, read \`.flumecode/wiki/README.md\` first \u2014 it is the index \u2014 and follow its links to the pages and source paths relevant to this request. If there is no wiki, work from the code directly.`;
|
|
371
|
+
const widgets = `When you need the user to choose, ask it as a widget rather than writing the options as prose: call \`single_select\` for a one-of-N choice (radio buttons) or \`multi_select\` for a "select all that apply" choice (checkboxes). Don't add your own "Other" option \u2014 the UI always provides one. After calling a widget tool, end your turn \u2014 the user's answer comes back as their next message and starts a fresh run.`;
|
|
372
|
+
const lines = [
|
|
373
|
+
`You are "${ctx.agentName}", an autonomous coding agent working inside a FlumeCode request.`,
|
|
374
|
+
`The repository ${ctx.repo.fullName} is checked out in your current working directory on branch "${ctx.repo.checkoutBranch}" at commit ${ctx.repo.checkoutSha.slice(0, 7)}.`,
|
|
375
|
+
task,
|
|
376
|
+
orient,
|
|
377
|
+
widgets
|
|
378
|
+
];
|
|
379
|
+
if (ctx.permissionMode !== "plan") {
|
|
380
|
+
lines.push(
|
|
381
|
+
"",
|
|
382
|
+
"These coding guidelines apply to all code produced in this run:",
|
|
383
|
+
"",
|
|
384
|
+
loadRule("coding-guideline")
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
lines.push("", `# Request: ${ctx.request?.title ?? ""}`);
|
|
388
|
+
if (ctx.request?.body) {
|
|
389
|
+
lines.push("", ctx.request.body);
|
|
390
|
+
}
|
|
391
|
+
if (ctx.thread && ctx.thread.length > 0) {
|
|
392
|
+
lines.push("", "# Conversation so far");
|
|
393
|
+
for (const turn of ctx.thread) {
|
|
394
|
+
lines.push("", `## ${turn.role === "agent" ? ctx.agentName : "User"}`, turn.content);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
lines.push(
|
|
398
|
+
"",
|
|
399
|
+
ctx.permissionMode === "plan" ? "Your final reply is posted verbatim as your comment in the thread \u2014 if you called `submit_plan`, the rendered plan is posted automatically; for clarifying questions, your reply text is posted as-is." : "Your final reply is posted verbatim as your comment in the thread \u2014 make it the implementation report your report subagent produced, with nothing added. The runner appends the pull-request link."
|
|
400
|
+
);
|
|
401
|
+
return lines.join("\n");
|
|
402
|
+
}
|
|
403
|
+
function buildDocumentPrompt(ctx) {
|
|
404
|
+
const lines = [
|
|
405
|
+
`You are "${ctx.agentName}" maintaining the repository wiki for ${ctx.repo.fullName}.`,
|
|
406
|
+
`An implementation just ran in this working directory to satisfy the request below; its changes are uncommitted in the working tree.`,
|
|
407
|
+
`Use the \`flumecode:document\` skill to bring the wiki in sync with those changes. Only edit files under \`.flumecode/wiki/\` \u2014 do not touch application code. The runner commits the wiki alongside the implementation in the same pull request.`,
|
|
408
|
+
"",
|
|
409
|
+
`# Request: ${ctx.request?.title ?? ""}`
|
|
410
|
+
];
|
|
411
|
+
if (ctx.request?.body) {
|
|
412
|
+
lines.push("", ctx.request.body);
|
|
413
|
+
}
|
|
414
|
+
if (ctx.thread && ctx.thread.length > 0) {
|
|
415
|
+
lines.push("", "# Conversation so far");
|
|
416
|
+
for (const turn of ctx.thread) {
|
|
417
|
+
lines.push("", `## ${turn.role === "agent" ? ctx.agentName : "User"}`, turn.content);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
lines.push("", "When done, reply with a one- or two-line summary of the wiki changes you made.");
|
|
421
|
+
return lines.join("\n");
|
|
422
|
+
}
|
|
423
|
+
function buildInitPrompt(ctx) {
|
|
424
|
+
return [
|
|
425
|
+
`You are "${ctx.agentName}" initializing FlumeCode for the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
|
|
426
|
+
`Use the \`flumecode:document\` skill to create the initial repository wiki under \`.flumecode/wiki/\`. The wiki does not exist yet, so the skill will bootstrap it: survey the codebase and produce a high-level overview plus per-component pages. Only create files under \`.flumecode/\` \u2014 do not modify application code. The runner commits the result and opens a pull request.`,
|
|
427
|
+
"",
|
|
428
|
+
"When done, reply with a one- or two-line summary of the wiki you created."
|
|
429
|
+
].join("\n");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// src/types.ts
|
|
433
|
+
function jobTitle(ctx) {
|
|
434
|
+
return ctx.kind === "init" ? "Initialize FlumeCode wiki" : ctx.request?.title ?? "request";
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// src/workspace.ts
|
|
438
|
+
import { execFile } from "node:child_process";
|
|
439
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
440
|
+
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
|
441
|
+
import { tmpdir } from "node:os";
|
|
442
|
+
import { join as join3 } from "node:path";
|
|
443
|
+
import { promisify } from "node:util";
|
|
444
|
+
var exec = promisify(execFile);
|
|
445
|
+
var WORKSPACE_PREFIX = "flume-runner-";
|
|
446
|
+
var MAX_BUFFER = 1 << 24;
|
|
447
|
+
async function git(args) {
|
|
448
|
+
return exec("git", args, { maxBuffer: MAX_BUFFER });
|
|
449
|
+
}
|
|
450
|
+
function cloneUrl(ctx) {
|
|
451
|
+
const { owner, name, cloneToken } = ctx.repo;
|
|
452
|
+
return `https://x-access-token:${cloneToken}@github.com/${owner}/${name}.git`;
|
|
453
|
+
}
|
|
454
|
+
function detectPackageManager(dir) {
|
|
455
|
+
if (!existsSync2(join3(dir, "package.json"))) return null;
|
|
456
|
+
if (existsSync2(join3(dir, "pnpm-lock.yaml"))) return "pnpm";
|
|
457
|
+
if (existsSync2(join3(dir, "yarn.lock"))) return "yarn";
|
|
458
|
+
if (existsSync2(join3(dir, "package-lock.json"))) return "npm";
|
|
459
|
+
if (existsSync2(join3(dir, "bun.lockb"))) return "bun";
|
|
460
|
+
return "npm";
|
|
461
|
+
}
|
|
462
|
+
async function installDependencies(dir) {
|
|
463
|
+
const manager = detectPackageManager(dir);
|
|
464
|
+
if (manager === null) return { status: "skipped" };
|
|
465
|
+
const env = { ...process.env, CI: "1", ADBLOCK: "1", DISABLE_OPENCOLLECTIVE: "1" };
|
|
466
|
+
try {
|
|
467
|
+
await exec(manager, ["install"], { cwd: dir, maxBuffer: MAX_BUFFER, env, timeout: 5 * 6e4 });
|
|
468
|
+
return { status: "installed", manager };
|
|
469
|
+
} catch (err) {
|
|
470
|
+
return { status: "failed", manager, error: err instanceof Error ? err.message : String(err) };
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
async function makeWorkspace() {
|
|
474
|
+
return mkdtemp(join3(tmpdir(), WORKSPACE_PREFIX));
|
|
475
|
+
}
|
|
476
|
+
async function sweepWorkspaces() {
|
|
477
|
+
const base = tmpdir();
|
|
478
|
+
let entries;
|
|
479
|
+
try {
|
|
480
|
+
entries = await readdir(base);
|
|
481
|
+
} catch {
|
|
482
|
+
return 0;
|
|
483
|
+
}
|
|
484
|
+
let removed = 0;
|
|
485
|
+
for (const entry of entries) {
|
|
486
|
+
if (!entry.startsWith(WORKSPACE_PREFIX)) continue;
|
|
487
|
+
try {
|
|
488
|
+
await rm(join3(base, entry), { recursive: true, force: true });
|
|
489
|
+
removed++;
|
|
490
|
+
} catch {
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return removed;
|
|
494
|
+
}
|
|
495
|
+
async function cloneAtSha(ctx, dir) {
|
|
496
|
+
await git(["clone", "--quiet", cloneUrl(ctx), dir]);
|
|
497
|
+
await git(["-C", dir, "checkout", "-B", ctx.repo.checkoutBranch, ctx.repo.checkoutSha]);
|
|
498
|
+
}
|
|
499
|
+
async function hasChanges(dir) {
|
|
500
|
+
await git(["-C", dir, "add", "-A"]);
|
|
501
|
+
const { stdout: stdout2 } = await git(["-C", dir, "status", "--porcelain"]);
|
|
502
|
+
return stdout2.trim().length > 0;
|
|
503
|
+
}
|
|
504
|
+
async function commitAndPush(ctx, dir) {
|
|
505
|
+
if (!await hasChanges(dir)) return false;
|
|
506
|
+
await git([
|
|
507
|
+
"-C",
|
|
508
|
+
dir,
|
|
509
|
+
"-c",
|
|
510
|
+
"user.email=runner@flumecode.local",
|
|
511
|
+
"-c",
|
|
512
|
+
"user.name=FlumeCode Runner",
|
|
513
|
+
"commit",
|
|
514
|
+
"--quiet",
|
|
515
|
+
"-m",
|
|
516
|
+
`FlumeCode: ${jobTitle(ctx)}`
|
|
517
|
+
]);
|
|
518
|
+
await git(["-C", dir, "push", "--quiet", "-u", "origin", ctx.repo.checkoutBranch]);
|
|
519
|
+
return true;
|
|
520
|
+
}
|
|
521
|
+
async function openPullRequest(ctx) {
|
|
522
|
+
const { owner, name, cloneToken, checkoutBranch, mergeBranch } = ctx.repo;
|
|
523
|
+
if (!mergeBranch) return null;
|
|
524
|
+
const apiBase = `https://api.github.com/repos/${owner}/${name}`;
|
|
525
|
+
const headers = {
|
|
526
|
+
authorization: `Bearer ${cloneToken}`,
|
|
527
|
+
accept: "application/vnd.github+json",
|
|
528
|
+
"x-github-api-version": "2022-11-28",
|
|
529
|
+
"content-type": "application/json"
|
|
530
|
+
};
|
|
531
|
+
const title = jobTitle(ctx);
|
|
532
|
+
const body = ctx.kind === "init" ? "Bootstraps the `.flumecode/` wiki for this repository. Opened by the FlumeCode runner." : `Opened by the FlumeCode runner for request "${title}".`;
|
|
533
|
+
const res = await fetch(`${apiBase}/pulls`, {
|
|
534
|
+
method: "POST",
|
|
535
|
+
headers,
|
|
536
|
+
body: JSON.stringify({
|
|
537
|
+
title: `FlumeCode: ${title}`,
|
|
538
|
+
head: checkoutBranch,
|
|
539
|
+
base: mergeBranch,
|
|
540
|
+
body
|
|
541
|
+
})
|
|
542
|
+
});
|
|
543
|
+
if (res.status === 201) {
|
|
544
|
+
const data = await res.json();
|
|
545
|
+
return { number: data.number, url: data.html_url };
|
|
546
|
+
}
|
|
547
|
+
if (res.status === 422) {
|
|
548
|
+
const list = await fetch(
|
|
549
|
+
`${apiBase}/pulls?state=open&head=${owner}:${checkoutBranch}&base=${mergeBranch}`,
|
|
550
|
+
{ headers }
|
|
551
|
+
);
|
|
552
|
+
if (list.ok) {
|
|
553
|
+
const open = await list.json();
|
|
554
|
+
if (open[0]) return { number: open[0].number, url: open[0].html_url };
|
|
555
|
+
}
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
throw new Error(`PR creation failed: ${res.status} ${await res.text()}`);
|
|
559
|
+
}
|
|
560
|
+
async function cleanup(dir) {
|
|
561
|
+
await rm(dir, { recursive: true, force: true });
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// src/run.ts
|
|
565
|
+
var IDLE_MS = 5e3;
|
|
566
|
+
var ORCHESTRATOR_MODEL = "sonnet";
|
|
567
|
+
var ORCHESTRATOR_MAX_TURNS = 80;
|
|
568
|
+
var INIT_MAX_TURNS = 200;
|
|
569
|
+
var DOCUMENT_MAX_TURNS = 120;
|
|
570
|
+
var HEARTBEAT_MS = 5 * 6e4;
|
|
571
|
+
async function pushAndOpenPr(ctx, dir) {
|
|
572
|
+
const pushed = await commitAndPush(ctx, dir);
|
|
573
|
+
if (!pushed) return { kind: "none" };
|
|
574
|
+
const pr = await openPullRequest(ctx);
|
|
575
|
+
return pr ? { kind: "pr", pr } : { kind: "pushed" };
|
|
576
|
+
}
|
|
577
|
+
function outcomeBanner(outcome, opts) {
|
|
578
|
+
const wikiNote = opts.documented ? " (with wiki updates)" : "";
|
|
579
|
+
switch (outcome.kind) {
|
|
580
|
+
case "pr":
|
|
581
|
+
return `
|
|
582
|
+
|
|
583
|
+
---
|
|
584
|
+
\u2705 Opened pull request from \`${opts.branch}\`${wikiNote} \xB7 [View pull request](${outcome.pr.url})`;
|
|
585
|
+
case "pushed":
|
|
586
|
+
return `
|
|
587
|
+
|
|
588
|
+
---
|
|
589
|
+
\u26A0\uFE0F Pushed \`${opts.branch}\`${wikiNote}, but couldn't open a pull request (no diff against the base branch, or one is already open).`;
|
|
590
|
+
case "none":
|
|
591
|
+
return `
|
|
592
|
+
|
|
593
|
+
---
|
|
594
|
+
\u2139\uFE0F **No code changes were made** \u2014 ${opts.noChange ?? "there was nothing to open a pull request for."}`;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
async function processJob(ctx) {
|
|
598
|
+
const dir = await makeWorkspace();
|
|
599
|
+
try {
|
|
600
|
+
if (ctx.kind === "init") return await processInitJob(ctx, dir);
|
|
601
|
+
if (ctx.kind === "implement") return await processImplementJob(ctx, dir);
|
|
602
|
+
return await processChatJob(ctx, dir);
|
|
603
|
+
} finally {
|
|
604
|
+
await cleanup(dir);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
async function processInitJob(ctx, dir) {
|
|
608
|
+
console.log(`
|
|
609
|
+
\u25B6 Init ${ctx.jobId} \u2014 ${ctx.repo.fullName} on ${ctx.repo.checkoutBranch}`);
|
|
610
|
+
await cloneAtSha(ctx, dir);
|
|
611
|
+
const summary = (await runClaudeCode({
|
|
612
|
+
cwd: dir,
|
|
613
|
+
prompt: buildInitPrompt(ctx),
|
|
614
|
+
permissionMode: ctx.permissionMode,
|
|
615
|
+
maxTurns: INIT_MAX_TURNS
|
|
616
|
+
})).text.trim();
|
|
617
|
+
let reply = summary || "(the agent produced no summary)";
|
|
618
|
+
const outcome = await pushAndOpenPr(ctx, dir);
|
|
619
|
+
reply += outcomeBanner(outcome, {
|
|
620
|
+
branch: ctx.repo.checkoutBranch,
|
|
621
|
+
noChange: "no files were generated; the wiki may already exist."
|
|
622
|
+
});
|
|
623
|
+
return { text: reply, widgets: [] };
|
|
624
|
+
}
|
|
625
|
+
async function processChatJob(ctx, dir) {
|
|
626
|
+
console.log(`
|
|
627
|
+
\u25B6 Job ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
|
|
628
|
+
await cloneAtSha(ctx, dir);
|
|
629
|
+
const orchestrating = ctx.permissionMode !== "plan";
|
|
630
|
+
const installResult = orchestrating ? await installDependencies(dir) : null;
|
|
631
|
+
const result = await runClaudeCode({
|
|
632
|
+
cwd: dir,
|
|
633
|
+
prompt: buildPrompt(ctx),
|
|
634
|
+
permissionMode: ctx.permissionMode,
|
|
635
|
+
...orchestrating ? { model: ORCHESTRATOR_MODEL, maxTurns: ORCHESTRATOR_MAX_TURNS } : {}
|
|
636
|
+
});
|
|
637
|
+
const summary = result.text.trim();
|
|
638
|
+
let reply = summary || "(the agent produced no summary)";
|
|
639
|
+
if (result.plan) {
|
|
640
|
+
reply = result.plan;
|
|
641
|
+
}
|
|
642
|
+
if (installResult?.status === "failed") {
|
|
643
|
+
reply += `
|
|
644
|
+
|
|
645
|
+
> \u26A0\uFE0F Dependencies failed to install (\`${installResult.manager}\`); tests may not have run.`;
|
|
646
|
+
}
|
|
647
|
+
if (result.widgets.length > 0) {
|
|
648
|
+
console.log(` \u2026job ${ctx.jobId} posted ${result.widgets.length} widget(s); awaiting reply`);
|
|
649
|
+
return { text: reply, widgets: result.widgets };
|
|
650
|
+
}
|
|
651
|
+
let documented = false;
|
|
652
|
+
if (ctx.permissionMode !== "plan" && await hasChanges(dir)) {
|
|
653
|
+
try {
|
|
654
|
+
console.log(` \u2026updating wiki for job ${ctx.jobId}`);
|
|
655
|
+
await runClaudeCode({
|
|
656
|
+
cwd: dir,
|
|
657
|
+
prompt: buildDocumentPrompt(ctx),
|
|
658
|
+
permissionMode: ctx.permissionMode,
|
|
659
|
+
maxTurns: DOCUMENT_MAX_TURNS
|
|
660
|
+
});
|
|
661
|
+
documented = true;
|
|
662
|
+
} catch (err) {
|
|
663
|
+
console.warn(` wiki update skipped: ${errorMessage2(err)}`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
const outcome = await pushAndOpenPr(ctx, dir);
|
|
667
|
+
reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented });
|
|
668
|
+
return { text: reply, widgets: [] };
|
|
669
|
+
}
|
|
670
|
+
async function processImplementJob(ctx, dir) {
|
|
671
|
+
console.log(`
|
|
672
|
+
\u25B6 Implement ${ctx.jobId} \u2014 ${ctx.repo.fullName}: "${jobTitle(ctx)}"`);
|
|
673
|
+
await cloneAtSha(ctx, dir);
|
|
674
|
+
const installResult = await installDependencies(dir);
|
|
675
|
+
const result = await runClaudeCode({
|
|
676
|
+
cwd: dir,
|
|
677
|
+
prompt: buildPrompt(ctx),
|
|
678
|
+
permissionMode: ctx.permissionMode,
|
|
679
|
+
model: ORCHESTRATOR_MODEL,
|
|
680
|
+
maxTurns: ORCHESTRATOR_MAX_TURNS
|
|
681
|
+
});
|
|
682
|
+
let reply = result.text.trim() || "(the agent produced no report)";
|
|
683
|
+
if (installResult.status === "failed") {
|
|
684
|
+
reply += `
|
|
685
|
+
|
|
686
|
+
> \u26A0\uFE0F Dependencies failed to install (\`${installResult.manager}\`); tests may not have run.`;
|
|
687
|
+
}
|
|
688
|
+
let documented = false;
|
|
689
|
+
if (await hasChanges(dir)) {
|
|
690
|
+
try {
|
|
691
|
+
console.log(` \u2026updating wiki for implement ${ctx.jobId}`);
|
|
692
|
+
await runClaudeCode({
|
|
693
|
+
cwd: dir,
|
|
694
|
+
prompt: buildDocumentPrompt(ctx),
|
|
695
|
+
permissionMode: ctx.permissionMode,
|
|
696
|
+
maxTurns: DOCUMENT_MAX_TURNS
|
|
697
|
+
});
|
|
698
|
+
documented = true;
|
|
699
|
+
} catch (err) {
|
|
700
|
+
console.warn(` wiki update skipped: ${errorMessage2(err)}`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
const outcome = await pushAndOpenPr(ctx, dir);
|
|
704
|
+
reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented });
|
|
705
|
+
return { text: reply, widgets: [], ...outcome.kind === "pr" ? { pr: outcome.pr } : {} };
|
|
706
|
+
}
|
|
707
|
+
async function heartbeat(config) {
|
|
708
|
+
const health = await checkClaudeCode();
|
|
709
|
+
if (health.ready) {
|
|
710
|
+
console.log("\u2665 Claude Code auth verified \u2014 ready for jobs.");
|
|
711
|
+
} else {
|
|
712
|
+
console.warn(`\u2665 Claude Code not ready: ${health.error ?? "unknown reason"}`);
|
|
713
|
+
}
|
|
714
|
+
try {
|
|
715
|
+
await reportHeartbeat(config, health);
|
|
716
|
+
} catch (err) {
|
|
717
|
+
console.error(`Heartbeat report failed: ${errorMessage2(err)}`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
async function pollLoop(config) {
|
|
721
|
+
console.log(`FlumeCode runner connected to ${config.serverUrl}. Polling for jobs\u2026`);
|
|
722
|
+
const swept = await sweepWorkspaces();
|
|
723
|
+
if (swept > 0) console.log(`Cleared ${swept} stale workspace${swept === 1 ? "" : "s"}.`);
|
|
724
|
+
await heartbeat(config);
|
|
725
|
+
let nextHeartbeatAt = Date.now() + HEARTBEAT_MS;
|
|
726
|
+
for (; ; ) {
|
|
727
|
+
if (Date.now() >= nextHeartbeatAt) {
|
|
728
|
+
await heartbeat(config);
|
|
729
|
+
nextHeartbeatAt = Date.now() + HEARTBEAT_MS;
|
|
730
|
+
}
|
|
731
|
+
let ctx = null;
|
|
732
|
+
try {
|
|
733
|
+
ctx = await claimJob(config);
|
|
734
|
+
} catch (err) {
|
|
735
|
+
console.error(`Claim error: ${errorMessage2(err)}`);
|
|
736
|
+
await sleep(IDLE_MS);
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
if (!ctx) {
|
|
740
|
+
await sleep(IDLE_MS);
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
try {
|
|
744
|
+
const { text, widgets, pr } = await processJob(ctx);
|
|
745
|
+
await reportJob(config, ctx.jobId, { status: "done", text, widgets, pr });
|
|
746
|
+
console.log(`\u2713 Job ${ctx.jobId} done`);
|
|
747
|
+
} catch (err) {
|
|
748
|
+
const message = errorMessage2(err);
|
|
749
|
+
console.error(`\u2717 Job ${ctx.jobId} failed: ${message}`);
|
|
750
|
+
try {
|
|
751
|
+
await reportJob(config, ctx.jobId, { status: "error", error: message });
|
|
752
|
+
} catch (reportErr) {
|
|
753
|
+
console.error(` (also failed to report the error: ${errorMessage2(reportErr)})`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
function sleep(ms) {
|
|
759
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
760
|
+
}
|
|
761
|
+
function errorMessage2(err) {
|
|
762
|
+
return err instanceof Error ? err.message : String(err);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// src/cli.ts
|
|
766
|
+
var DEFAULT_SERVER = process.env.FLUME_SERVER || "http://localhost:3000";
|
|
767
|
+
async function login(args) {
|
|
768
|
+
const flags = parseFlags(args);
|
|
769
|
+
let serverUrl = flags.server;
|
|
770
|
+
let token = flags.token;
|
|
771
|
+
if (!serverUrl || !token) {
|
|
772
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
773
|
+
if (!serverUrl) {
|
|
774
|
+
const answer = (await rl.question(`Server URL [${DEFAULT_SERVER}]: `)).trim();
|
|
775
|
+
serverUrl = answer || DEFAULT_SERVER;
|
|
776
|
+
}
|
|
777
|
+
if (!token) {
|
|
778
|
+
token = (await rl.question("Runner token (flrun_\u2026): ")).trim();
|
|
779
|
+
}
|
|
780
|
+
rl.close();
|
|
781
|
+
}
|
|
782
|
+
serverUrl = serverUrl.replace(/\/+$/, "");
|
|
783
|
+
if (!token) {
|
|
784
|
+
console.error("A runner token is required. Create one in the web app under Runners.");
|
|
785
|
+
process.exit(1);
|
|
786
|
+
}
|
|
787
|
+
writeConfig({ serverUrl, token });
|
|
788
|
+
console.log(`Saved runner config to ${configPath}.`);
|
|
789
|
+
console.log("Start the runner with: flumecode start");
|
|
790
|
+
}
|
|
791
|
+
async function start() {
|
|
792
|
+
process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
|
|
793
|
+
const config = readConfig();
|
|
794
|
+
if (!config) {
|
|
795
|
+
console.error("Not logged in. Run: flumecode login");
|
|
796
|
+
process.exit(1);
|
|
797
|
+
}
|
|
798
|
+
await pollLoop(config);
|
|
799
|
+
}
|
|
800
|
+
function parseFlags(args) {
|
|
801
|
+
const out = {};
|
|
802
|
+
for (let i = 0; i < args.length; i++) {
|
|
803
|
+
if (args[i] === "--server") out.server = args[i + 1];
|
|
804
|
+
else if (args[i] === "--token") out.token = args[i + 1];
|
|
805
|
+
}
|
|
806
|
+
return out;
|
|
807
|
+
}
|
|
808
|
+
var command = process.argv[2];
|
|
809
|
+
var rest = process.argv.slice(3);
|
|
810
|
+
if (command === "login") {
|
|
811
|
+
void login(rest);
|
|
812
|
+
} else if (command === "start") {
|
|
813
|
+
void start();
|
|
814
|
+
} else {
|
|
815
|
+
console.log("FlumeCode runner");
|
|
816
|
+
console.log("Usage:");
|
|
817
|
+
console.log(" flumecode login # save server URL + token");
|
|
818
|
+
console.log(" flumecode start # poll for and run jobs");
|
|
819
|
+
process.exit(command ? 1 : 0);
|
|
820
|
+
}
|