cueloop 0.1.0-alpha.8 → 0.1.0-alpha.81
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/package.json +30 -23
- package/src/actions-command.ts +49 -0
- package/src/args.ts +21 -10
- package/src/codex-hook-command.ts +21 -0
- package/src/codex-mcp-command.ts +104 -0
- package/src/daemon-control.test.ts +68 -0
- package/src/daemon-control.ts +104 -0
- package/src/dev-command.ts +112 -0
- package/src/harness-command.ts +16 -0
- package/src/main.ts +330 -95
- package/src/open-target.test.ts +241 -0
- package/src/open-target.ts +128 -0
- package/src/pr.ts +56 -48
- package/src/refine-command.test.ts +187 -0
- package/src/refine-command.ts +45 -0
- package/src/share-command.test.ts +361 -0
- package/src/share-command.ts +147 -0
- package/src/thread-commands.ts +510 -0
- package/src/update-command.test.ts +203 -0
- package/src/update-command.ts +198 -0
- package/src/version.ts +5 -0
- package/src/session-cmds.ts +0 -107
- package/src/working-tree.ts +0 -27
package/package.json
CHANGED
|
@@ -1,37 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cueloop",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.81",
|
|
4
|
+
"description": "Terminal review surface for coding agents: annotate plans and diffs, return a message, send structured feedback back",
|
|
5
|
+
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/mmurakaru/cueloop/issues"
|
|
8
|
+
},
|
|
4
9
|
"license": "Apache-2.0",
|
|
5
|
-
"
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
13
|
+
"directory": "packages/cli"
|
|
14
|
+
},
|
|
6
15
|
"bin": {
|
|
7
16
|
"cueloop": "./src/main.ts"
|
|
8
17
|
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
9
23
|
"exports": {
|
|
10
24
|
".": "./src/main.ts"
|
|
11
25
|
},
|
|
12
|
-
"dependencies": {
|
|
13
|
-
"@cueloop/schema": "0.1.0-alpha.8",
|
|
14
|
-
"@cueloop/daemon": "0.1.0-alpha.8",
|
|
15
|
-
"@cueloop/client": "0.1.0-alpha.8"
|
|
16
|
-
},
|
|
17
26
|
"publishConfig": {
|
|
18
27
|
"access": "public"
|
|
19
28
|
},
|
|
20
|
-
"
|
|
21
|
-
"src"
|
|
22
|
-
"README.md"
|
|
23
|
-
],
|
|
24
|
-
"engines": {
|
|
25
|
-
"bun": ">=1.3.0"
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build:binary": "bun build --compile --minify --external cpu-features ./src/main.ts --outfile dist/cueloop"
|
|
26
31
|
},
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@cueloop/adapters": "0.1.0-alpha.81",
|
|
34
|
+
"@cueloop/client": "0.1.0-alpha.81",
|
|
35
|
+
"@cueloop/daemon": "0.1.0-alpha.81",
|
|
36
|
+
"@cueloop/schema": "0.1.0-alpha.81",
|
|
37
|
+
"@modelcontextprotocol/server": "2.0.0",
|
|
38
|
+
"@valibot/to-json-schema": "1.4.0",
|
|
39
|
+
"valibot": "^1.4.2"
|
|
35
40
|
},
|
|
36
|
-
"
|
|
37
|
-
|
|
41
|
+
"engines": {
|
|
42
|
+
"bun": ">=1.3.0"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cueloop actions list [--session <id>]` - print the quick-action vocabulary
|
|
3
|
+
* (the same presets a human picks in the marker popover) so a review-side agent
|
|
4
|
+
* can reference one by name or index via `session annotate --action`. With
|
|
5
|
+
* `--session`, the vocabulary comes from that session's repo (matching what
|
|
6
|
+
* `--action` will resolve); otherwise from the caller's cwd. JSON on stdout.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { loadConfig } from "@cueloop/client/config";
|
|
10
|
+
import { DaemonClient } from "@cueloop/daemon/client";
|
|
11
|
+
import { parseArgs, stringFlag } from "./args";
|
|
12
|
+
|
|
13
|
+
export async function actionsCommand(argv: string[]): Promise<number> {
|
|
14
|
+
const { positional, flags } = parseArgs(argv);
|
|
15
|
+
const primitive = positional[0] ?? "list";
|
|
16
|
+
|
|
17
|
+
if (primitive !== "list") {
|
|
18
|
+
console.error("usage: cueloop actions list [--session <id>]");
|
|
19
|
+
|
|
20
|
+
return 2;
|
|
21
|
+
}
|
|
22
|
+
const role = stringFlag(flags, "role") === "agent" ? "agent" : undefined;
|
|
23
|
+
const repoRoot = await repoRootForSession(stringFlag(flags, "session"), role);
|
|
24
|
+
const actions = loadConfig({ repoRoot }).actions;
|
|
25
|
+
const numbered = actions.map((action, index) => ({
|
|
26
|
+
index: index + 1,
|
|
27
|
+
prompt: action.prompt,
|
|
28
|
+
metadata: action.metadata,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
console.log(JSON.stringify(numbered, null, 2));
|
|
32
|
+
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The repo the given session lives in, or the caller's cwd when no session is named. */
|
|
37
|
+
async function repoRootForSession(
|
|
38
|
+
sessionId: string | undefined,
|
|
39
|
+
role: "agent" | undefined,
|
|
40
|
+
): Promise<string> {
|
|
41
|
+
if (sessionId === undefined) return process.cwd();
|
|
42
|
+
const client = await DaemonClient.connect({ autostart: true, role });
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
return (await client.sessionGet(sessionId)).workspace.repoRoot;
|
|
46
|
+
} finally {
|
|
47
|
+
client.close();
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/args.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** Tiny flag parser: --key value, --key=value, boolean --flag, positionals. */
|
|
2
2
|
|
|
3
|
+
import * as v from "valibot";
|
|
4
|
+
|
|
3
5
|
export interface ParsedArgs {
|
|
4
6
|
positional: string[];
|
|
5
7
|
flags: Record<string, string | boolean>;
|
|
@@ -8,18 +10,22 @@ export interface ParsedArgs {
|
|
|
8
10
|
export function parseArgs(argv: string[]): ParsedArgs {
|
|
9
11
|
const positional: string[] = [];
|
|
10
12
|
const flags: Record<string, string | boolean> = {};
|
|
13
|
+
|
|
11
14
|
for (let i = 0; i < argv.length; i++) {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
const argument = argv[i]!;
|
|
16
|
+
|
|
17
|
+
if (!argument.startsWith("--")) {
|
|
18
|
+
positional.push(argument);
|
|
15
19
|
continue;
|
|
16
20
|
}
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
const equalsIndex = argument.indexOf("=");
|
|
22
|
+
|
|
23
|
+
if (equalsIndex !== -1) {
|
|
24
|
+
flags[argument.slice(2, equalsIndex)] = argument.slice(equalsIndex + 1);
|
|
20
25
|
} else {
|
|
21
|
-
const key =
|
|
26
|
+
const key = argument.slice(2);
|
|
22
27
|
const next = argv[i + 1];
|
|
28
|
+
|
|
23
29
|
if (next !== undefined && !next.startsWith("--")) {
|
|
24
30
|
flags[key] = next;
|
|
25
31
|
i++;
|
|
@@ -28,10 +34,15 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
28
34
|
}
|
|
29
35
|
}
|
|
30
36
|
}
|
|
37
|
+
|
|
31
38
|
return { positional, flags };
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
export function
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
export function stringFlag(
|
|
42
|
+
flags: Record<string, string | boolean>,
|
|
43
|
+
key: string,
|
|
44
|
+
): string | undefined {
|
|
45
|
+
const result = v.safeParse(v.string(), flags[key]);
|
|
46
|
+
|
|
47
|
+
return result.success ? result.output : undefined;
|
|
37
48
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { CodexHookInputSchema, runCodexHook } from "@cueloop/adapters/codex/hook";
|
|
2
|
+
import * as v from "valibot";
|
|
3
|
+
|
|
4
|
+
/** Read one Codex lifecycle event and return its optional tool rewrite. */
|
|
5
|
+
export async function codexHookCommand(): Promise<number> {
|
|
6
|
+
try {
|
|
7
|
+
const input = v.parse(
|
|
8
|
+
CodexHookInputSchema,
|
|
9
|
+
JSON.parse(await new Response(Bun.stdin.stream()).text()),
|
|
10
|
+
);
|
|
11
|
+
const output = runCodexHook(input);
|
|
12
|
+
|
|
13
|
+
if (output) console.log(JSON.stringify(output));
|
|
14
|
+
|
|
15
|
+
return 0;
|
|
16
|
+
} catch (error) {
|
|
17
|
+
console.error(`cueloop Codex hook failed: ${String(error)}`);
|
|
18
|
+
|
|
19
|
+
return 1;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
3
|
+
import { toJsonSchema } from "@valibot/to-json-schema";
|
|
4
|
+
import * as v from "valibot";
|
|
5
|
+
import { createCodexDeliveryService } from "@cueloop/adapters/codex/delivery-service";
|
|
6
|
+
import { createCodexSessionRegistry } from "@cueloop/adapters/codex/session-registry";
|
|
7
|
+
import { runHarnessBridge } from "@cueloop/adapters/harness-bridge";
|
|
8
|
+
import { cueloopHome } from "@cueloop/daemon/paths";
|
|
9
|
+
import { WORKFLOW_KINDS } from "@cueloop/schema";
|
|
10
|
+
import { CLI_VERSION } from "./version";
|
|
11
|
+
|
|
12
|
+
const OpenThreadInputSchema = v.object({
|
|
13
|
+
workflow: v.picklist(WORKFLOW_KINDS),
|
|
14
|
+
content: v.optional(v.string()),
|
|
15
|
+
proposal: v.optional(v.string()),
|
|
16
|
+
pullRequestReference: v.optional(v.string()),
|
|
17
|
+
title: v.optional(v.string()),
|
|
18
|
+
harnessSessionId: v.pipe(v.string(), v.minLength(1)),
|
|
19
|
+
cwd: v.string(),
|
|
20
|
+
hookToken: v.pipe(v.string(), v.minLength(1)),
|
|
21
|
+
});
|
|
22
|
+
const RefineCorpusInputSchema = v.object({});
|
|
23
|
+
|
|
24
|
+
function mcpInputSchema<TSchema extends v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>>(
|
|
25
|
+
schema: TSchema,
|
|
26
|
+
) {
|
|
27
|
+
const convert = () => toJsonSchema(schema);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
...schema,
|
|
31
|
+
"~standard": {
|
|
32
|
+
...schema["~standard"],
|
|
33
|
+
jsonSchema: { input: convert, output: convert },
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Expose the shared Thread workflows to a standard Codex session. */
|
|
39
|
+
export function createCodexMcpServer(home = cueloopHome()): McpServer {
|
|
40
|
+
const server = new McpServer({ name: "cueloop", version: CLI_VERSION });
|
|
41
|
+
const sessions = createCodexSessionRegistry(home);
|
|
42
|
+
|
|
43
|
+
server.registerTool(
|
|
44
|
+
"open_thread",
|
|
45
|
+
{
|
|
46
|
+
description:
|
|
47
|
+
"Submit or revise a cueloop Thread for plan, reply, prototype, diff, review, or refine.",
|
|
48
|
+
inputSchema: mcpInputSchema(OpenThreadInputSchema),
|
|
49
|
+
},
|
|
50
|
+
async (input) => {
|
|
51
|
+
try {
|
|
52
|
+
if (!sessions.authorized(input.harnessSessionId, input.cwd, input.hookToken)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
"cueloop Codex session is not authorized; trust the plugin hooks and restart Codex",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const { hookToken: _hookToken, ...threadInput } = input;
|
|
58
|
+
const result = await runHarnessBridge(
|
|
59
|
+
{ operation: "open", harness: "codex", ...threadInput },
|
|
60
|
+
home,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return { isError: true, content: [{ type: "text", text: String(error) }] };
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
server.registerTool(
|
|
70
|
+
"refine_corpus",
|
|
71
|
+
{
|
|
72
|
+
description: "Analyze past cueloop Threads before proposing a refine writeback.",
|
|
73
|
+
inputSchema: mcpInputSchema(RefineCorpusInputSchema),
|
|
74
|
+
},
|
|
75
|
+
async () => {
|
|
76
|
+
try {
|
|
77
|
+
const result = await runHarnessBridge({ operation: "refine" }, home);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
content: [{ type: "text", text: result.operation === "refine" ? result.report : "" }],
|
|
81
|
+
};
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return { isError: true, content: [{ type: "text", text: String(error) }] };
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
return server;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Keep the MCP transport and durable Codex Message poller alive together. */
|
|
92
|
+
export async function codexMcpCommand(): Promise<number> {
|
|
93
|
+
const delivery = createCodexDeliveryService();
|
|
94
|
+
|
|
95
|
+
delivery.start();
|
|
96
|
+
const server = serveStdio(() => createCodexMcpServer(), {
|
|
97
|
+
onerror: (error) => console.error(`cueloop Codex MCP: ${String(error)}`),
|
|
98
|
+
});
|
|
99
|
+
await new Promise<void>((resolve) => process.stdin.once("end", resolve));
|
|
100
|
+
delivery.stop();
|
|
101
|
+
await server.close();
|
|
102
|
+
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cueloop stop`'s primitive against a real detached daemon: stopping it clears
|
|
3
|
+
* the socket and pid so the next launch autostarts a fresh one (the self-heal
|
|
4
|
+
* `cueloop update` relies on), and stopping when none runs is a no-op.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
8
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { DaemonClient } from "@cueloop/daemon/client";
|
|
12
|
+
import { pidPath, socketPath } from "@cueloop/daemon/paths";
|
|
13
|
+
import { restartCommand, stopDaemon } from "./daemon-control";
|
|
14
|
+
|
|
15
|
+
let home: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-stop-"));
|
|
19
|
+
});
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await stopDaemon(home).catch(() => undefined);
|
|
22
|
+
rmSync(home, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("stopDaemon stops a running daemon and clears its socket and pid", async () => {
|
|
26
|
+
// Arrange - a real detached daemon owns the home
|
|
27
|
+
const client = await DaemonClient.connect({ home, autostart: true });
|
|
28
|
+
|
|
29
|
+
client.close();
|
|
30
|
+
expect(existsSync(socketPath(home))).toBe(true);
|
|
31
|
+
|
|
32
|
+
// Act
|
|
33
|
+
const stopped = await stopDaemon(home);
|
|
34
|
+
|
|
35
|
+
// Assert - it reported a stop, and the daemon released its socket and pid
|
|
36
|
+
expect(stopped).toBe(true);
|
|
37
|
+
expect(existsSync(socketPath(home))).toBe(false);
|
|
38
|
+
expect(existsSync(pidPath(home))).toBe(false);
|
|
39
|
+
}, 60_000);
|
|
40
|
+
|
|
41
|
+
test("stopDaemon reports nothing to stop when no daemon is running", async () => {
|
|
42
|
+
expect(await stopDaemon(home)).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("restartCommand replaces the daemon and reconnects", async () => {
|
|
46
|
+
const previousHome = process.env.CUELOOP_HOME;
|
|
47
|
+
const first = await DaemonClient.connect({ home, autostart: true });
|
|
48
|
+
const firstPid = (await first.ping()).pid;
|
|
49
|
+
|
|
50
|
+
first.close();
|
|
51
|
+
process.env.CUELOOP_HOME = home;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
expect(await restartCommand()).toBe(0);
|
|
55
|
+
|
|
56
|
+
const restarted = await DaemonClient.connect({ home, autostart: false });
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
expect((await restarted.ping()).pid).not.toBe(firstPid);
|
|
60
|
+
expect(existsSync(socketPath(home))).toBe(true);
|
|
61
|
+
} finally {
|
|
62
|
+
restarted.close();
|
|
63
|
+
}
|
|
64
|
+
} finally {
|
|
65
|
+
if (previousHome === undefined) delete process.env.CUELOOP_HOME;
|
|
66
|
+
else process.env.CUELOOP_HOME = previousHome;
|
|
67
|
+
}
|
|
68
|
+
}, 60_000);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local daemon control: `cueloop stop` and `cueloop restart`, and the
|
|
3
|
+
* `stopDaemon` primitive `cueloop update` calls so a newer binary is not left
|
|
4
|
+
* talking to the old-version daemon (which refuses the handshake).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import * as v from "valibot";
|
|
9
|
+
import { DaemonClient } from "@cueloop/daemon/client";
|
|
10
|
+
import { cueloopHome, pidPath, socketPath } from "@cueloop/daemon/paths";
|
|
11
|
+
|
|
12
|
+
const ProcessIdSchema = v.pipe(
|
|
13
|
+
v.string(),
|
|
14
|
+
v.transform((raw) => Number(raw.trim())),
|
|
15
|
+
v.integer(),
|
|
16
|
+
v.minValue(1),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
/** Signal the daemon through its pid file; callers only reach this while a socket proves it is live. */
|
|
20
|
+
function signalDaemonByProcessId(home: string): boolean {
|
|
21
|
+
const path = pidPath(home);
|
|
22
|
+
|
|
23
|
+
if (!existsSync(path)) return false;
|
|
24
|
+
let raw: string;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
raw = readFileSync(path, "utf8");
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
const parsed = v.safeParse(ProcessIdSchema, raw);
|
|
32
|
+
|
|
33
|
+
if (!parsed.success) return false;
|
|
34
|
+
try {
|
|
35
|
+
process.kill(parsed.output);
|
|
36
|
+
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True once the stopped daemon has released its socket; false if it is still there at the deadline. */
|
|
44
|
+
async function waitForSocketGone(home: string): Promise<boolean> {
|
|
45
|
+
const path = socketPath(home);
|
|
46
|
+
const deadline = Date.now() + 5_000;
|
|
47
|
+
|
|
48
|
+
while (Date.now() < deadline) {
|
|
49
|
+
if (!existsSync(path)) return true;
|
|
50
|
+
await Bun.sleep(50);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return !existsSync(path);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Stop the daemon that owns `home`, returning true once its socket is released.
|
|
58
|
+
* A live daemon is stopped by the owner-gated shutdown request, or, when it
|
|
59
|
+
* refuses the handshake (a version mismatch), by signalling its pid - safe
|
|
60
|
+
* because the socket proves the pid file is current. A home with no socket has
|
|
61
|
+
* no live daemon to stop, so its pid file is never trusted.
|
|
62
|
+
*/
|
|
63
|
+
export async function stopDaemon(home = cueloopHome()): Promise<boolean> {
|
|
64
|
+
if (!existsSync(socketPath(home))) return false;
|
|
65
|
+
try {
|
|
66
|
+
const client = await DaemonClient.connect({ home, autostart: false });
|
|
67
|
+
|
|
68
|
+
await client.shutdown();
|
|
69
|
+
client.close();
|
|
70
|
+
} catch {
|
|
71
|
+
if (!signalDaemonByProcessId(home)) return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return waitForSocketGone(home);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `cueloop stop` - stop the local daemon. */
|
|
78
|
+
export async function stopCommand(): Promise<number> {
|
|
79
|
+
const home = cueloopHome();
|
|
80
|
+
|
|
81
|
+
if (!existsSync(socketPath(home))) {
|
|
82
|
+
console.log("no cueloop daemon is running");
|
|
83
|
+
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
const stopped = await stopDaemon(home);
|
|
87
|
+
|
|
88
|
+
console.log(stopped ? "cueloop daemon stopped" : "cueloop daemon did not stop in time");
|
|
89
|
+
|
|
90
|
+
return stopped ? 0 : 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `cueloop restart` - stop the local daemon, then autostart a fresh one. */
|
|
94
|
+
export async function restartCommand(): Promise<number> {
|
|
95
|
+
const home = cueloopHome();
|
|
96
|
+
|
|
97
|
+
await stopDaemon(home);
|
|
98
|
+
const client = await DaemonClient.connect({ home, autostart: true });
|
|
99
|
+
|
|
100
|
+
client.close();
|
|
101
|
+
console.log("cueloop daemon restarted");
|
|
102
|
+
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cueloop dev`: open the TUI against an isolated dev home seeded once with a
|
|
3
|
+
* plan and a diff under this repo's project plus a standalone reply thread, so
|
|
4
|
+
* the Projects and Threads sidebar and the thread view are populated without a
|
|
5
|
+
* live agent. It never touches the real ~/.cueloop home.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { homedir, tmpdir } from "node:os";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { DaemonClient } from "@cueloop/daemon/client";
|
|
12
|
+
import { resolveWorkspace } from "@cueloop/daemon/thread-review";
|
|
13
|
+
|
|
14
|
+
/** Bump whenever the seed content below changes, so a long-lived dev home refreshes instead of keeping
|
|
15
|
+
* stale threads (an old seed's diff had no files, which showed as "No changes"). */
|
|
16
|
+
const SEED_VERSION = 2;
|
|
17
|
+
const SEED_TITLES = new Set([
|
|
18
|
+
"Read the repository",
|
|
19
|
+
"Review the accent change",
|
|
20
|
+
"A standalone thought",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const SEED_PLAN = `# Read the repository
|
|
24
|
+
|
|
25
|
+
A seeded plan so \`cueloop dev\` always has a thread to open.
|
|
26
|
+
|
|
27
|
+
## Steps
|
|
28
|
+
- open the thread view
|
|
29
|
+
- move the cursor into the agent's text
|
|
30
|
+
- type to leave a comment
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
const SEED_REPLY = `A standalone thought, not tied to any repository.
|
|
34
|
+
|
|
35
|
+
It lands under Threads in the sidebar rather than a project.
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
const SEED_DIFF_PATH = "packages/client/src/theme.ts";
|
|
39
|
+
const SEED_DIFF_OLD = ' accent: "#f5a3a3",\n';
|
|
40
|
+
const SEED_DIFF_NEW = ' accent: "#cba6f7",\n';
|
|
41
|
+
const SEED_DIFF = `diff --git a/${SEED_DIFF_PATH} b/${SEED_DIFF_PATH}
|
|
42
|
+
--- a/${SEED_DIFF_PATH}
|
|
43
|
+
+++ b/${SEED_DIFF_PATH}
|
|
44
|
+
@@ -1 +1 @@
|
|
45
|
+
-${SEED_DIFF_OLD.trimEnd()}
|
|
46
|
+
+${SEED_DIFF_NEW.trimEnd()}
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
async function seedDevSessions(client: DaemonClient): Promise<void> {
|
|
50
|
+
// the current repo carries a root commit, so its threads gather under a project
|
|
51
|
+
const project = await resolveWorkspace(process.cwd());
|
|
52
|
+
// a non-repo directory has no root commit, so its thread stays standalone
|
|
53
|
+
const standalone = await resolveWorkspace(tmpdir());
|
|
54
|
+
|
|
55
|
+
await client.sessionCreate(project, {
|
|
56
|
+
type: "plan",
|
|
57
|
+
content: SEED_PLAN,
|
|
58
|
+
meta: { title: "Read the repository" },
|
|
59
|
+
});
|
|
60
|
+
await client.sessionCreate(project, {
|
|
61
|
+
type: "diff",
|
|
62
|
+
content: SEED_DIFF,
|
|
63
|
+
meta: { title: "Review the accent change" },
|
|
64
|
+
files: [
|
|
65
|
+
{
|
|
66
|
+
path: SEED_DIFF_PATH,
|
|
67
|
+
oldContents: SEED_DIFF_OLD,
|
|
68
|
+
newContents: SEED_DIFF_NEW,
|
|
69
|
+
status: "modified",
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
});
|
|
73
|
+
await client.sessionCreate(standalone, {
|
|
74
|
+
type: "reply",
|
|
75
|
+
content: SEED_REPLY,
|
|
76
|
+
meta: { title: "A standalone thought" },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Seed the dev home, refreshing the seed threads when SEED_VERSION moved on so a stale home never
|
|
81
|
+
* keeps an old seed (which is why an aged home showed "No changes" - its diff thread had no files). */
|
|
82
|
+
async function refreshDevSeed(client: DaemonClient, home: string): Promise<void> {
|
|
83
|
+
const versionFile = join(home, ".seed-version");
|
|
84
|
+
const stored = existsSync(versionFile) ? Number(readFileSync(versionFile, "utf8").trim()) : 0;
|
|
85
|
+
const existing = await client.sessionList();
|
|
86
|
+
if (existing.length > 0 && stored === SEED_VERSION) return;
|
|
87
|
+
|
|
88
|
+
await Promise.all(
|
|
89
|
+
existing
|
|
90
|
+
.filter((thread) => SEED_TITLES.has(thread.artifact.meta.title ?? ""))
|
|
91
|
+
.map((thread) => client.sessionDelete(thread.id)),
|
|
92
|
+
);
|
|
93
|
+
await seedDevSessions(client);
|
|
94
|
+
mkdirSync(dirname(versionFile), { recursive: true });
|
|
95
|
+
writeFileSync(versionFile, String(SEED_VERSION));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** `cueloop dev`: seed an isolated dev home (refreshing a stale seed), then open the TUI on it. */
|
|
99
|
+
export async function devCommand(): Promise<number> {
|
|
100
|
+
const home = (process.env.CUELOOP_HOME ??= join(homedir(), ".cueloop-dev"));
|
|
101
|
+
|
|
102
|
+
const client = await DaemonClient.connect({ autostart: true });
|
|
103
|
+
try {
|
|
104
|
+
await refreshDevSeed(client, home);
|
|
105
|
+
} finally {
|
|
106
|
+
client.close();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const { runClient } = await import("@cueloop/client");
|
|
110
|
+
|
|
111
|
+
return runClient({});
|
|
112
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { runHarnessBridge } from "@cueloop/adapters/harness-bridge";
|
|
2
|
+
|
|
3
|
+
export async function harnessCommand(): Promise<number> {
|
|
4
|
+
try {
|
|
5
|
+
const request = JSON.parse(await new Response(Bun.stdin.stream()).text());
|
|
6
|
+
const response = await runHarnessBridge(request);
|
|
7
|
+
|
|
8
|
+
console.log(JSON.stringify(response));
|
|
9
|
+
|
|
10
|
+
return 0;
|
|
11
|
+
} catch (error) {
|
|
12
|
+
console.error(`cueloop harness bridge failed: ${String(error)}`);
|
|
13
|
+
|
|
14
|
+
return 1;
|
|
15
|
+
}
|
|
16
|
+
}
|