cueloop 0.1.0-alpha.10 → 0.1.0-alpha.101
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 +31 -23
- package/src/actions-command.ts +49 -0
- package/src/agent-harness.ts +32 -0
- package/src/args.ts +23 -12
- package/src/codex-delivery-worker-command.test.ts +75 -0
- package/src/codex-delivery-worker-command.ts +99 -0
- package/src/codex-hook-command.ts +21 -0
- package/src/codex-mcp-command.ts +94 -0
- package/src/daemon-command.ts +57 -0
- package/src/daemon-control.test.ts +68 -0
- package/src/daemon-control.ts +104 -0
- package/src/dev-command.ts +122 -0
- package/src/harness-command.ts +16 -0
- package/src/install-command.test.ts +142 -0
- package/src/install-command.ts +115 -0
- package/src/main.ts +383 -92
- package/src/open-target.test.ts +241 -0
- package/src/open-target.ts +126 -0
- package/src/pr.ts +464 -64
- package/src/refine-command.test.ts +187 -0
- package/src/refine-command.ts +45 -0
- package/src/share-command.test.ts +386 -0
- package/src/share-command.ts +161 -0
- package/src/thread-commands.ts +559 -0
- package/src/update-command.test.ts +203 -0
- package/src/update-command.ts +195 -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,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cueloop",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.101",
|
|
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.10",
|
|
14
|
-
"@cueloop/daemon": "0.1.0-alpha.10",
|
|
15
|
-
"@cueloop/client": "0.1.0-alpha.10"
|
|
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 --bytecode --format=esm --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.101",
|
|
34
|
+
"@cueloop/client": "0.1.0-alpha.101",
|
|
35
|
+
"@cueloop/daemon": "0.1.0-alpha.101",
|
|
36
|
+
"@cueloop/extension-api": "0.1.0-alpha.101",
|
|
37
|
+
"@cueloop/schema": "0.1.0-alpha.101",
|
|
38
|
+
"@modelcontextprotocol/server": "2.1.0",
|
|
39
|
+
"@valibot/to-json-schema": "1.8.0",
|
|
40
|
+
"valibot": "^1.5.0"
|
|
35
41
|
},
|
|
36
|
-
"
|
|
37
|
-
|
|
42
|
+
"engines": {
|
|
43
|
+
"bun": ">=1.3.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createPiHarness } from "@cueloop/adapters/pi/durable-harness";
|
|
2
|
+
import { cueloopHome } from "@cueloop/daemon";
|
|
3
|
+
import { createFxHarness } from "@cueloop/adapters/fx/harness";
|
|
4
|
+
import { loadConfig } from "@cueloop/client/config";
|
|
5
|
+
import type { AgentHarnessAdapter } from "@cueloop/schema";
|
|
6
|
+
|
|
7
|
+
/** Select a locally installed harness; unsupported providers fail before a daemon starts. */
|
|
8
|
+
export function configuredAgentHarness(): AgentHarnessAdapter | undefined {
|
|
9
|
+
const id = process.env.CUELOOP_AGENT_HARNESS ?? "pi";
|
|
10
|
+
|
|
11
|
+
if (id === "pi") return createPiHarness({ home: cueloopHome() });
|
|
12
|
+
|
|
13
|
+
if (id !== "fx") throw new Error(`Thread agent harness is not installed: ${id}`);
|
|
14
|
+
|
|
15
|
+
return fxHarness();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Resolve the experimental Thread agent flag from user and workspace TOML. */
|
|
19
|
+
export function threadAgentEnabled(repoRoot: string): boolean {
|
|
20
|
+
return loadConfig({ repoRoot }).experimental.threadAgent;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function installedAgentHarnesses() {
|
|
24
|
+
return {
|
|
25
|
+
pi: createPiHarness({ home: cueloopHome() }),
|
|
26
|
+
fx: fxHarness(),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function fxHarness(): AgentHarnessAdapter {
|
|
31
|
+
return createFxHarness({ command: [process.env.CUELOOP_FX_EXECUTABLE ?? "fx", "acp"] });
|
|
32
|
+
}
|
package/src/args.ts
CHANGED
|
@@ -1,26 +1,32 @@
|
|
|
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>;
|
|
6
8
|
}
|
|
7
9
|
|
|
8
|
-
export function parseArgs(argv: string[]): ParsedArgs {
|
|
10
|
+
export function parseArgs(argv: string[], booleanFlags: readonly 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];
|
|
23
|
-
|
|
28
|
+
|
|
29
|
+
if (!booleanFlags.includes(key) && next !== undefined && !next.startsWith("--")) {
|
|
24
30
|
flags[key] = next;
|
|
25
31
|
i++;
|
|
26
32
|
} else {
|
|
@@ -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,75 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
6
|
+
import { DaemonClient } from "@cueloop/daemon/client";
|
|
7
|
+
import { createCodexSessionRegistry } from "@cueloop/adapters/codex/session-registry";
|
|
8
|
+
import { runHarnessBridge } from "@cueloop/adapters/harness-bridge";
|
|
9
|
+
import { startCodexDeliveryWorker } from "./codex-delivery-worker-command";
|
|
10
|
+
|
|
11
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-codex-worker-"));
|
|
12
|
+
const server = new DaemonServer({ home, idleExitMs: 0 });
|
|
13
|
+
const codexBin = join(home, "codex");
|
|
14
|
+
let worker: ReturnType<typeof Bun.spawn> | undefined;
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
worker?.kill();
|
|
18
|
+
await worker?.exited;
|
|
19
|
+
server.stop();
|
|
20
|
+
rmSync(home, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("a Codex reply is delivered without an MCP transport", async () => {
|
|
24
|
+
server.start();
|
|
25
|
+
writeFileSync(codexBin, '#!/bin/sh\nprintf "%s" "$5" > delivered.txt\n');
|
|
26
|
+
chmodSync(codexBin, 0o755);
|
|
27
|
+
createCodexSessionRegistry(home).activate("codex-session-1");
|
|
28
|
+
const opened = await runHarnessBridge(
|
|
29
|
+
{
|
|
30
|
+
operation: "open",
|
|
31
|
+
harness: "codex",
|
|
32
|
+
harnessSessionId: "codex-session-1",
|
|
33
|
+
cwd: home,
|
|
34
|
+
workflow: "plan",
|
|
35
|
+
content: "# Wait for feedback",
|
|
36
|
+
},
|
|
37
|
+
home,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
if (opened.operation !== "open") throw new Error("expected open Thread");
|
|
41
|
+
const previousCodexBin = process.env.CUELOOP_CODEX_BIN;
|
|
42
|
+
|
|
43
|
+
// A reused PID belonging to this test process must not impersonate the worker.
|
|
44
|
+
writeFileSync(join(home, "codex-delivery-worker.pid"), String(process.pid));
|
|
45
|
+
process.env.CUELOOP_CODEX_BIN = codexBin;
|
|
46
|
+
worker = startCodexDeliveryWorker(home, [
|
|
47
|
+
process.execPath,
|
|
48
|
+
"run",
|
|
49
|
+
import.meta.dir + "/main.ts",
|
|
50
|
+
"codex-delivery-worker",
|
|
51
|
+
]);
|
|
52
|
+
if (previousCodexBin === undefined) delete process.env.CUELOOP_CODEX_BIN;
|
|
53
|
+
else process.env.CUELOOP_CODEX_BIN = previousCodexBin;
|
|
54
|
+
expect(worker).toBeDefined();
|
|
55
|
+
const deadline = Date.now() + 5000;
|
|
56
|
+
|
|
57
|
+
while (!existsSync(join(home, "codex-delivery-worker.pid")) && Date.now() < deadline)
|
|
58
|
+
await Bun.sleep(20);
|
|
59
|
+
expect(existsSync(join(home, "codex-delivery-worker.pid"))).toBe(true);
|
|
60
|
+
const client = await DaemonClient.connect({ home });
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
await client.sessionSendMessage(opened.threadId, "approved", "Continue after MCP exit.");
|
|
64
|
+
const deliveryPath = join(home, "delivered.txt");
|
|
65
|
+
const delivered = () =>
|
|
66
|
+
existsSync(deliveryPath) &&
|
|
67
|
+
readFileSync(deliveryPath, "utf8").includes("Continue after MCP exit.");
|
|
68
|
+
|
|
69
|
+
// Shell redirection creates the file before printf writes the delivered message.
|
|
70
|
+
while (!delivered() && Date.now() < deadline) await Bun.sleep(20);
|
|
71
|
+
expect(delivered()).toBe(true);
|
|
72
|
+
} finally {
|
|
73
|
+
client.close();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { closeSync, openSync, readFileSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createCodexDeliveryService } from "@cueloop/adapters/codex/delivery-service";
|
|
4
|
+
import { createCodexSessionRegistry } from "@cueloop/adapters/codex/session-registry";
|
|
5
|
+
import { cueloopHome } from "@cueloop/daemon/paths";
|
|
6
|
+
|
|
7
|
+
const EMPTY_SESSION_GRACE_MS = 30_000;
|
|
8
|
+
|
|
9
|
+
function claimWorker(path: string): boolean {
|
|
10
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
11
|
+
try {
|
|
12
|
+
const descriptor = openSync(path, "wx", 0o600);
|
|
13
|
+
|
|
14
|
+
writeSync(descriptor, String(process.pid));
|
|
15
|
+
closeSync(descriptor);
|
|
16
|
+
|
|
17
|
+
return true;
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
|
|
20
|
+
const before = statSync(path);
|
|
21
|
+
const owner = Number(readFileSync(path, "utf8"));
|
|
22
|
+
|
|
23
|
+
if (Number.isSafeInteger(owner) && owner > 0) {
|
|
24
|
+
try {
|
|
25
|
+
process.kill(owner, 0);
|
|
26
|
+
const command = Bun.spawnSync(["ps", "-p", String(owner), "-o", "command="], {
|
|
27
|
+
stdout: "pipe",
|
|
28
|
+
stderr: "ignore",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (
|
|
32
|
+
command.exitCode === 0 &&
|
|
33
|
+
/(?:^|\s)codex-delivery-worker(?:\s|$)/.test(command.stdout.toString())
|
|
34
|
+
)
|
|
35
|
+
return false;
|
|
36
|
+
} catch (cause) {
|
|
37
|
+
if (!(cause instanceof Error && "code" in cause && cause.code === "ESRCH")) return false;
|
|
38
|
+
}
|
|
39
|
+
} else if (Date.now() - before.mtimeMs < 5_000) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
if (statSync(path).ino !== before.ino) return false;
|
|
43
|
+
unlinkSync(path);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Run durable Codex Message delivery after the MCP transport has closed. */
|
|
51
|
+
export async function codexDeliveryWorkerCommand(): Promise<number> {
|
|
52
|
+
const home = cueloopHome();
|
|
53
|
+
const lockPath = join(home, "codex-delivery-worker.pid");
|
|
54
|
+
|
|
55
|
+
if (!claimWorker(lockPath)) return 0;
|
|
56
|
+
const sessions = createCodexSessionRegistry(home);
|
|
57
|
+
const delivery = createCodexDeliveryService({
|
|
58
|
+
home,
|
|
59
|
+
codexBin: process.env.CUELOOP_CODEX_BIN,
|
|
60
|
+
});
|
|
61
|
+
let emptySince: number | undefined;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
delivery.start();
|
|
65
|
+
while (true) {
|
|
66
|
+
await Bun.sleep(1000);
|
|
67
|
+
emptySince = sessions.list().length === 0 ? (emptySince ?? Date.now()) : undefined;
|
|
68
|
+
|
|
69
|
+
if (emptySince !== undefined && Date.now() - emptySince >= EMPTY_SESSION_GRACE_MS) break;
|
|
70
|
+
}
|
|
71
|
+
} finally {
|
|
72
|
+
delivery.stop();
|
|
73
|
+
if (readFileSync(lockPath, "utf8") === String(process.pid)) unlinkSync(lockPath);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The child owns the poller, so MCP transport shutdown does not stop delivery. */
|
|
80
|
+
export function startCodexDeliveryWorker(
|
|
81
|
+
home: string,
|
|
82
|
+
command = [...process.argv.slice(0, -1), "codex-delivery-worker"],
|
|
83
|
+
): ReturnType<typeof Bun.spawn> | undefined {
|
|
84
|
+
try {
|
|
85
|
+
const child = Bun.spawn(command, {
|
|
86
|
+
env: { ...process.env, CUELOOP_HOME: home },
|
|
87
|
+
stdin: "ignore",
|
|
88
|
+
stdout: "ignore",
|
|
89
|
+
stderr: "ignore",
|
|
90
|
+
detached: true,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
child.unref();
|
|
94
|
+
|
|
95
|
+
return child;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.error(`cueloop Codex delivery worker: ${String(error)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -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,94 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
3
|
+
import { toStandardJsonSchema } 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
|
+
import { startCodexDeliveryWorker } from "./codex-delivery-worker-command";
|
|
12
|
+
|
|
13
|
+
const OpenThreadInputSchema = v.object({
|
|
14
|
+
workflow: v.picklist(WORKFLOW_KINDS),
|
|
15
|
+
content: v.optional(v.string()),
|
|
16
|
+
proposal: v.optional(v.string()),
|
|
17
|
+
pullRequestReference: v.optional(v.string()),
|
|
18
|
+
title: v.optional(v.string()),
|
|
19
|
+
harnessSessionId: v.pipe(v.string(), v.minLength(1)),
|
|
20
|
+
cwd: v.string(),
|
|
21
|
+
hookToken: v.pipe(v.string(), v.minLength(1)),
|
|
22
|
+
});
|
|
23
|
+
const RefineCorpusInputSchema = v.object({});
|
|
24
|
+
|
|
25
|
+
/** Expose the shared Thread workflows to a standard Codex session. */
|
|
26
|
+
export function createCodexMcpServer(home = cueloopHome()): McpServer {
|
|
27
|
+
const server = new McpServer({ name: "cueloop", version: CLI_VERSION });
|
|
28
|
+
const sessions = createCodexSessionRegistry(home);
|
|
29
|
+
|
|
30
|
+
server.registerTool(
|
|
31
|
+
"open_thread",
|
|
32
|
+
{
|
|
33
|
+
description:
|
|
34
|
+
"Submit or revise a cueloop Thread for plan, reply, prototype, diff, review, or refine.",
|
|
35
|
+
inputSchema: toStandardJsonSchema(OpenThreadInputSchema),
|
|
36
|
+
},
|
|
37
|
+
async (input) => {
|
|
38
|
+
try {
|
|
39
|
+
if (!sessions.authorized(input.harnessSessionId, input.cwd, input.hookToken)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
"cueloop Codex session is not authorized; trust the plugin hooks and restart Codex",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const { hookToken: _hookToken, ...threadInput } = input;
|
|
45
|
+
const result = await runHarnessBridge(
|
|
46
|
+
{ operation: "open", harness: "codex", ...threadInput },
|
|
47
|
+
home,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (result.operation === "open") startCodexDeliveryWorker(home);
|
|
51
|
+
|
|
52
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return { isError: true, content: [{ type: "text", text: String(error) }] };
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
server.registerTool(
|
|
59
|
+
"refine_corpus",
|
|
60
|
+
{
|
|
61
|
+
description: "Analyze past cueloop Threads before proposing a refine writeback.",
|
|
62
|
+
inputSchema: toStandardJsonSchema(RefineCorpusInputSchema),
|
|
63
|
+
},
|
|
64
|
+
async () => {
|
|
65
|
+
try {
|
|
66
|
+
const result = await runHarnessBridge({ operation: "refine" }, home);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
content: [{ type: "text", text: result.operation === "refine" ? result.report : "" }],
|
|
70
|
+
};
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return { isError: true, content: [{ type: "text", text: String(error) }] };
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return server;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Keep the MCP transport and durable Codex Message poller alive together. */
|
|
81
|
+
export async function codexMcpCommand(): Promise<number> {
|
|
82
|
+
const delivery = createCodexDeliveryService();
|
|
83
|
+
|
|
84
|
+
delivery.start();
|
|
85
|
+
const server = serveStdio(() => createCodexMcpServer(), {
|
|
86
|
+
onerror: (error) => console.error(`cueloop Codex MCP: ${String(error)}`),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await new Promise<void>((resolve) => process.stdin.once("end", resolve));
|
|
90
|
+
delivery.stop();
|
|
91
|
+
await server.close();
|
|
92
|
+
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { OwnerAgentRelay } from "@cueloop/adapters/owner-agent-relay";
|
|
2
|
+
/** Compose the local daemon with its installed harness adapters at the CLI boundary. */
|
|
3
|
+
export async function daemonCommand(argv: string[]): Promise<number> {
|
|
4
|
+
const { DaemonServer } = await import("@cueloop/daemon");
|
|
5
|
+
// Explicit foreground never idle-exits; --autostart (a re-exec from the client)
|
|
6
|
+
// takes the normal idle-exit, matching the source main.ts entry.
|
|
7
|
+
const envIdle = process.env.CUELOOP_IDLE_EXIT_MS;
|
|
8
|
+
let idleExitMs: number | undefined = 0;
|
|
9
|
+
|
|
10
|
+
if (argv.includes("--autostart")) idleExitMs = envIdle ? Number(envIdle) : undefined;
|
|
11
|
+
const { configuredAgentHarness, installedAgentHarnesses, threadAgentEnabled } =
|
|
12
|
+
await import("./agent-harness");
|
|
13
|
+
let relay: OwnerAgentRelay | undefined;
|
|
14
|
+
const server = new DaemonServer({
|
|
15
|
+
onEvent: (event) => {
|
|
16
|
+
relay?.notify(event);
|
|
17
|
+
if (event.event !== "agent.updated") relay?.update(server.core.sessionList());
|
|
18
|
+
},
|
|
19
|
+
idleExitMs,
|
|
20
|
+
threadAgent: {
|
|
21
|
+
enabled: true,
|
|
22
|
+
enabledForThread: (thread) => threadAgentEnabled(thread.workspace.repoRoot),
|
|
23
|
+
adapter: configuredAgentHarness(),
|
|
24
|
+
adapters: installedAgentHarnesses(),
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
const path = server.start();
|
|
28
|
+
|
|
29
|
+
if (path === null) {
|
|
30
|
+
console.error("a cueloop daemon already owns this home - nothing to do");
|
|
31
|
+
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
server.resumeAgents();
|
|
35
|
+
relay = new OwnerAgentRelay({
|
|
36
|
+
home: server.home,
|
|
37
|
+
enabled: (thread) => threadAgentEnabled(thread.workspace.repoRoot),
|
|
38
|
+
onError: (error) => console.error("[agent relay]", error),
|
|
39
|
+
});
|
|
40
|
+
relay.update(server.core.sessionList());
|
|
41
|
+
console.log(`cueloop daemon (foreground) on ${path}`);
|
|
42
|
+
const stop = async () => {
|
|
43
|
+
relay?.stop();
|
|
44
|
+
await server.shutdown();
|
|
45
|
+
process.exit(0);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
process.once("SIGINT", stop);
|
|
49
|
+
process.once("SIGTERM", stop);
|
|
50
|
+
process.once("exit", () => {
|
|
51
|
+
relay?.stop();
|
|
52
|
+
server.stop();
|
|
53
|
+
});
|
|
54
|
+
await new Promise(() => {}); // run until signalled
|
|
55
|
+
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
@@ -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);
|