cueloop 0.1.0-alpha.89 → 0.1.0-alpha.90
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 +6 -5
- package/src/codex-delivery-worker-command.test.ts +69 -0
- package/src/codex-delivery-worker-command.ts +99 -0
- package/src/codex-mcp-command.ts +3 -0
- package/src/install-command.test.ts +142 -0
- package/src/install-command.ts +114 -0
- package/src/main.ts +4 -0
- package/src/thread-commands.ts +20 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cueloop",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.90",
|
|
4
4
|
"description": "Terminal review surface for coding agents: annotate plans and diffs, return a message, send structured feedback back",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -30,10 +30,11 @@
|
|
|
30
30
|
"build:binary": "bun build --compile --minify --external cpu-features ./src/main.ts --outfile dist/cueloop"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@cueloop/adapters": "0.1.0-alpha.
|
|
34
|
-
"@cueloop/client": "0.1.0-alpha.
|
|
35
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
36
|
-
"@cueloop/
|
|
33
|
+
"@cueloop/adapters": "0.1.0-alpha.90",
|
|
34
|
+
"@cueloop/client": "0.1.0-alpha.90",
|
|
35
|
+
"@cueloop/daemon": "0.1.0-alpha.90",
|
|
36
|
+
"@cueloop/extension-api": "0.1.0-alpha.90",
|
|
37
|
+
"@cueloop/schema": "0.1.0-alpha.90",
|
|
37
38
|
"@modelcontextprotocol/server": "2.0.0",
|
|
38
39
|
"@valibot/to-json-schema": "1.4.0",
|
|
39
40
|
"valibot": "^1.5.0"
|
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
while (!existsSync(join(home, "delivered.txt")) && Date.now() < deadline) await Bun.sleep(20);
|
|
65
|
+
expect(readFileSync(join(home, "delivered.txt"), "utf8")).toContain("Continue after MCP exit.");
|
|
66
|
+
} finally {
|
|
67
|
+
client.close();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
@@ -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
|
+
}
|
package/src/codex-mcp-command.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { runHarnessBridge } from "@cueloop/adapters/harness-bridge";
|
|
|
8
8
|
import { cueloopHome } from "@cueloop/daemon/paths";
|
|
9
9
|
import { WORKFLOW_KINDS } from "@cueloop/schema";
|
|
10
10
|
import { CLI_VERSION } from "./version";
|
|
11
|
+
import { startCodexDeliveryWorker } from "./codex-delivery-worker-command";
|
|
11
12
|
|
|
12
13
|
const OpenThreadInputSchema = v.object({
|
|
13
14
|
workflow: v.picklist(WORKFLOW_KINDS),
|
|
@@ -60,6 +61,8 @@ export function createCodexMcpServer(home = cueloopHome()): McpServer {
|
|
|
60
61
|
home,
|
|
61
62
|
);
|
|
62
63
|
|
|
64
|
+
if (result.operation === "open") startCodexDeliveryWorker(home);
|
|
65
|
+
|
|
63
66
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
64
67
|
} catch (error) {
|
|
65
68
|
return { isError: true, content: [{ type: "text", text: String(error) }] };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { installExtensionCommand } from "./install-command";
|
|
6
|
+
|
|
7
|
+
const roots: string[] = [];
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function testRoot(): string {
|
|
14
|
+
const root = mkdtempSync(join(tmpdir(), "cueloop-install-test-"));
|
|
15
|
+
|
|
16
|
+
roots.push(root);
|
|
17
|
+
|
|
18
|
+
return root;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("cueloop install", () => {
|
|
22
|
+
test("rejects sources outside npm without running a package command", async () => {
|
|
23
|
+
const root = testRoot();
|
|
24
|
+
let called = false;
|
|
25
|
+
|
|
26
|
+
expect(
|
|
27
|
+
await installExtensionCommand(["file:./extension"], {
|
|
28
|
+
installRoot: root,
|
|
29
|
+
runPackageCommand: async () => {
|
|
30
|
+
called = true;
|
|
31
|
+
|
|
32
|
+
return { exitCode: 0, stderr: "" };
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
).toBe(2);
|
|
36
|
+
expect(called).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("installs a package whose manifest declares a client entry", async () => {
|
|
40
|
+
const root = testRoot();
|
|
41
|
+
const commands: string[][] = [];
|
|
42
|
+
const result = await installExtensionCommand(["npm:@example/tools@1.0.0"], {
|
|
43
|
+
installRoot: root,
|
|
44
|
+
runPackageCommand: async (args) => {
|
|
45
|
+
commands.push(args);
|
|
46
|
+
const packageRoot = join(root, "node_modules", "@example/tools");
|
|
47
|
+
|
|
48
|
+
mkdirSync(packageRoot, { recursive: true });
|
|
49
|
+
writeFileSync(
|
|
50
|
+
join(root, "package.json"),
|
|
51
|
+
JSON.stringify({ dependencies: { "@example/tools": "1.0.0" } }),
|
|
52
|
+
);
|
|
53
|
+
writeFileSync(
|
|
54
|
+
join(packageRoot, "package.json"),
|
|
55
|
+
JSON.stringify({ cueloop: { client: "./client.js" } }),
|
|
56
|
+
);
|
|
57
|
+
writeFileSync(join(packageRoot, "client.js"), "export default () => {};");
|
|
58
|
+
|
|
59
|
+
return { exitCode: 0, stderr: "" };
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(result).toBe(0);
|
|
64
|
+
expect(commands).toEqual([["add", "--ignore-scripts", "--cwd", root, "@example/tools@1.0.0"]]);
|
|
65
|
+
expect(JSON.parse(readFileSync(join(root, "package.json"), "utf8")).dependencies).toEqual({
|
|
66
|
+
"@example/tools": "1.0.0",
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("removes a package without a cueloop manifest", async () => {
|
|
71
|
+
const root = testRoot();
|
|
72
|
+
const commands: string[][] = [];
|
|
73
|
+
const result = await installExtensionCommand(["npm:example"], {
|
|
74
|
+
installRoot: root,
|
|
75
|
+
runPackageCommand: async (args) => {
|
|
76
|
+
commands.push(args);
|
|
77
|
+
if (args[0] === "add") {
|
|
78
|
+
const packageRoot = join(root, "node_modules", "example");
|
|
79
|
+
|
|
80
|
+
mkdirSync(packageRoot, { recursive: true });
|
|
81
|
+
writeFileSync(
|
|
82
|
+
join(root, "package.json"),
|
|
83
|
+
JSON.stringify({ dependencies: { example: "1.0.0" } }),
|
|
84
|
+
);
|
|
85
|
+
writeFileSync(join(packageRoot, "package.json"), "{}");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { exitCode: 0, stderr: "" };
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(result).toBe(1);
|
|
93
|
+
expect(commands[1]).toEqual(["remove", "--ignore-scripts", "--cwd", root, "example"]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("restores the prior version after an invalid upgrade", async () => {
|
|
97
|
+
const root = testRoot();
|
|
98
|
+
const packageRoot = join(root, "node_modules", "example");
|
|
99
|
+
const commands: string[][] = [];
|
|
100
|
+
|
|
101
|
+
mkdirSync(packageRoot, { recursive: true });
|
|
102
|
+
writeFileSync(
|
|
103
|
+
join(root, "package.json"),
|
|
104
|
+
JSON.stringify({ dependencies: { example: "1.0.0" } }),
|
|
105
|
+
);
|
|
106
|
+
writeFileSync(
|
|
107
|
+
join(packageRoot, "package.json"),
|
|
108
|
+
JSON.stringify({ version: "1.0.0", cueloop: { client: "./client.js" } }),
|
|
109
|
+
);
|
|
110
|
+
writeFileSync(join(packageRoot, "client.js"), "export default () => {};");
|
|
111
|
+
const result = await installExtensionCommand(["npm:example@2.0.0"], {
|
|
112
|
+
installRoot: root,
|
|
113
|
+
runPackageCommand: async (args) => {
|
|
114
|
+
commands.push(args);
|
|
115
|
+
if (args.at(-1) === "example@2.0.0") {
|
|
116
|
+
writeFileSync(
|
|
117
|
+
join(root, "package.json"),
|
|
118
|
+
JSON.stringify({ dependencies: { example: "2.0.0" } }),
|
|
119
|
+
);
|
|
120
|
+
writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ version: "2.0.0" }));
|
|
121
|
+
} else {
|
|
122
|
+
writeFileSync(
|
|
123
|
+
join(root, "package.json"),
|
|
124
|
+
JSON.stringify({ dependencies: { example: "1.0.0" } }),
|
|
125
|
+
);
|
|
126
|
+
writeFileSync(
|
|
127
|
+
join(packageRoot, "package.json"),
|
|
128
|
+
JSON.stringify({ version: "1.0.0", cueloop: { client: "./client.js" } }),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { exitCode: 0, stderr: "" };
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(result).toBe(1);
|
|
137
|
+
expect(commands[1]).toEqual(["add", "--ignore-scripts", "--cwd", root, "example@1.0.0"]);
|
|
138
|
+
expect(JSON.parse(readFileSync(join(root, "package.json"), "utf8")).dependencies.example).toBe(
|
|
139
|
+
"1.0.0",
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import * as v from "valibot";
|
|
4
|
+
import {
|
|
5
|
+
discoverInstalledExtensionPackages,
|
|
6
|
+
extensionInstallRoot,
|
|
7
|
+
} from "@cueloop/extension-api/installed-packages";
|
|
8
|
+
|
|
9
|
+
const NPM_PACKAGE_SOURCE = /^npm:((?:@[a-z0-9_.-]+\/)?[a-z0-9_.-]+)(?:@([^\s/]+))?$/i;
|
|
10
|
+
|
|
11
|
+
interface PackageCommandResult {
|
|
12
|
+
exitCode: number;
|
|
13
|
+
stderr: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface InstallOptions {
|
|
17
|
+
installRoot?: string;
|
|
18
|
+
runPackageCommand?: (args: string[]) => Promise<PackageCommandResult>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function runPackageCommand(args: string[]): Promise<PackageCommandResult> {
|
|
22
|
+
// A compiled executable can invoke its bundled Bun CLI without `bun` on PATH.
|
|
23
|
+
const child = Bun.spawn([process.execPath, ...args], {
|
|
24
|
+
env: { ...process.env, BUN_BE_BUN: "1" },
|
|
25
|
+
stdout: "ignore",
|
|
26
|
+
stderr: "pipe",
|
|
27
|
+
});
|
|
28
|
+
const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
|
|
29
|
+
|
|
30
|
+
return { exitCode, stderr };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function previousPackageSpec(installRoot: string, name: string): string | null {
|
|
34
|
+
const path = join(installRoot, "node_modules", name, "package.json");
|
|
35
|
+
|
|
36
|
+
if (!existsSync(path)) return null;
|
|
37
|
+
try {
|
|
38
|
+
const parsed = v.safeParse(
|
|
39
|
+
v.object({ version: v.pipe(v.string(), v.minLength(1)) }),
|
|
40
|
+
JSON.parse(readFileSync(path, "utf8")),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return parsed.success ? `${name}@${parsed.output.version}` : null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Install a user-level npm extension after checking its declared entry points. */
|
|
50
|
+
export async function installExtensionCommand(
|
|
51
|
+
argv: string[],
|
|
52
|
+
options: InstallOptions = {},
|
|
53
|
+
): Promise<number> {
|
|
54
|
+
const source = argv[0];
|
|
55
|
+
const match = source?.match(NPM_PACKAGE_SOURCE);
|
|
56
|
+
|
|
57
|
+
if (argv.length !== 1 || !match) {
|
|
58
|
+
console.error("cueloop install: expected npm:<package>[@version]");
|
|
59
|
+
|
|
60
|
+
return 2;
|
|
61
|
+
}
|
|
62
|
+
const name = match[1]!;
|
|
63
|
+
const spec = source!.slice("npm:".length);
|
|
64
|
+
const installRoot = options.installRoot ?? extensionInstallRoot();
|
|
65
|
+
const runCommand = options.runPackageCommand ?? runPackageCommand;
|
|
66
|
+
const previousSpec = previousPackageSpec(installRoot, name);
|
|
67
|
+
|
|
68
|
+
mkdirSync(installRoot, { recursive: true });
|
|
69
|
+
const rootManifest = join(installRoot, "package.json");
|
|
70
|
+
|
|
71
|
+
if (!existsSync(rootManifest)) {
|
|
72
|
+
writeFileSync(
|
|
73
|
+
rootManifest,
|
|
74
|
+
JSON.stringify({ name: "cueloop-extensions", private: true }, null, 2),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const { exitCode, stderr } = await runCommand([
|
|
78
|
+
"add",
|
|
79
|
+
"--ignore-scripts",
|
|
80
|
+
"--cwd",
|
|
81
|
+
installRoot,
|
|
82
|
+
spec,
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
if (exitCode !== 0) {
|
|
86
|
+
console.error(`cueloop install: ${stderr.trim() || "bun add failed"}`);
|
|
87
|
+
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
const discovered = discoverInstalledExtensionPackages(installRoot);
|
|
91
|
+
const installed = discovered.packages.find((entry) => entry.name === name);
|
|
92
|
+
|
|
93
|
+
if (!installed) {
|
|
94
|
+
const error = discovered.errors.find((message) => message.includes(` ${name}:`));
|
|
95
|
+
console.error(`cueloop install: ${error ?? `extension ${name} has no valid cueloop manifest`}`);
|
|
96
|
+
const restored = await runCommand(
|
|
97
|
+
previousSpec
|
|
98
|
+
? ["add", "--ignore-scripts", "--cwd", installRoot, previousSpec]
|
|
99
|
+
: ["remove", "--ignore-scripts", "--cwd", installRoot, name],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (restored.exitCode !== 0)
|
|
103
|
+
console.error(
|
|
104
|
+
`cueloop install: could not restore ${previousSpec ?? name}: ${restored.stderr.trim()}`,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
console.log(
|
|
110
|
+
`Installed ${name}. Restart cueloop and run 'cueloop restart' to load daemon capabilities.`,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -114,10 +114,13 @@ const commandHandlers: CommandHandlers = {
|
|
|
114
114
|
share: (rest) => shareEntry(rest),
|
|
115
115
|
harness: async () => (await import("./harness-command")).harnessCommand(),
|
|
116
116
|
"codex-hook": async () => (await import("./codex-hook-command")).codexHookCommand(),
|
|
117
|
+
"codex-delivery-worker": async () =>
|
|
118
|
+
(await import("./codex-delivery-worker-command")).codexDeliveryWorkerCommand(),
|
|
117
119
|
mcp: async () => (await import("./codex-mcp-command")).codexMcpCommand(),
|
|
118
120
|
actions: async (rest) => (await import("./actions-command")).actionsCommand(rest),
|
|
119
121
|
refine: async (rest) => (await import("./refine-command")).refineCommand(rest),
|
|
120
122
|
update: async (rest) => (await import("./update-command")).updateCommand(rest),
|
|
123
|
+
install: async (rest) => (await import("./install-command")).installExtensionCommand(rest),
|
|
121
124
|
review: (rest) => reviewEntry(rest),
|
|
122
125
|
"review-config": async () => (await import("./pr")).reviewConfigCommand(),
|
|
123
126
|
"review-comment": async (rest) => (await import("./pr")).reviewCommentCommand(rest),
|
|
@@ -349,6 +352,7 @@ function printHelp(): void {
|
|
|
349
352
|
" cueloop actions list list the quick-action vocabulary (for annotate --action)",
|
|
350
353
|
" cueloop refine mine past reviews into a markdown report + writeback proposals",
|
|
351
354
|
" cueloop update [--dry-run] update the installed cueloop binary (--dry-run reports the target only)",
|
|
355
|
+
" cueloop install npm:<package> install a user-level extension package",
|
|
352
356
|
" cueloop review-post <id> explicitly post selected agent findings to the PR",
|
|
353
357
|
" cueloop review-comment <id> add or update an agent PR finding (see --help)",
|
|
354
358
|
" cueloop daemon run the daemon in the foreground",
|
package/src/thread-commands.ts
CHANGED
|
@@ -55,6 +55,7 @@ async function sessionCreate({ client, flags }: SessionContext): Promise<number>
|
|
|
55
55
|
type,
|
|
56
56
|
content,
|
|
57
57
|
cwd: stringFlag(flags, "cwd"),
|
|
58
|
+
vcs: stringFlag(flags, "vcs"),
|
|
58
59
|
agent: stringFlag(flags, "agent"),
|
|
59
60
|
agentSessionId: stringFlag(flags, "agent-session-id"),
|
|
60
61
|
planPath: stringFlag(flags, "plan-path"),
|
|
@@ -420,8 +421,26 @@ async function sessionSubmitRevisionCommand({
|
|
|
420
421
|
.split(",")
|
|
421
422
|
.map((annotationId) => annotationId.trim())
|
|
422
423
|
.filter(Boolean);
|
|
424
|
+
const session = await client.sessionGet(id);
|
|
425
|
+
let files;
|
|
426
|
+
let source;
|
|
427
|
+
|
|
428
|
+
if (session.artifact.type === "diff" && session.artifact.meta.pr === undefined) {
|
|
429
|
+
files = [];
|
|
430
|
+
try {
|
|
431
|
+
const captured = await client.repoDiff(session.workspace.repoRoot, session.artifact.meta.vcs);
|
|
432
|
+
|
|
433
|
+
if (captured.patch.replace(/\n+$/, "") === content.replace(/\n+$/, "")) {
|
|
434
|
+
files = captured.files;
|
|
435
|
+
source =
|
|
436
|
+
captured.source && captured.vcs ? { vcs: captured.vcs, ...captured.source } : undefined;
|
|
437
|
+
}
|
|
438
|
+
} catch {
|
|
439
|
+
// Keep a supplied patch reviewable without attaching stale file contents.
|
|
440
|
+
}
|
|
441
|
+
}
|
|
423
442
|
|
|
424
|
-
out(await client.sessionSubmitRevision(id, content, addressed));
|
|
443
|
+
out(await client.sessionSubmitRevision(id, content, addressed, files, source));
|
|
425
444
|
|
|
426
445
|
return 0;
|
|
427
446
|
}
|