cueloop 0.1.0-alpha.9 → 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 CHANGED
@@ -1,37 +1,45 @@
1
1
  {
2
2
  "name": "cueloop",
3
- "version": "0.1.0-alpha.9",
3
+ "version": "0.1.0-alpha.90",
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
- "type": "module",
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.9",
14
- "@cueloop/daemon": "0.1.0-alpha.9",
15
- "@cueloop/client": "0.1.0-alpha.9"
16
- },
17
26
  "publishConfig": {
18
27
  "access": "public"
19
28
  },
20
- "files": [
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
- "repository": {
28
- "type": "git",
29
- "url": "git+https://github.com/mmurakaru/cueloop.git",
30
- "directory": "packages/cli"
31
- },
32
- "homepage": "https://github.com/mmurakaru/cueloop#readme",
33
- "bugs": {
34
- "url": "https://github.com/mmurakaru/cueloop/issues"
32
+ "dependencies": {
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",
38
+ "@modelcontextprotocol/server": "2.0.0",
39
+ "@valibot/to-json-schema": "1.4.0",
40
+ "valibot": "^1.5.0"
35
41
  },
36
- "description": "Terminal review surface for coding agents: annotate plans and diffs, return a verdict, send structured feedback back"
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
+ }
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 a = argv[i]!;
13
- if (!a.startsWith("--")) {
14
- positional.push(a);
15
+ const argument = argv[i]!;
16
+
17
+ if (!argument.startsWith("--")) {
18
+ positional.push(argument);
15
19
  continue;
16
20
  }
17
- const eq = a.indexOf("=");
18
- if (eq !== -1) {
19
- flags[a.slice(2, eq)] = a.slice(eq + 1);
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 = a.slice(2);
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 flagStr(flags: Record<string, string | boolean>, key: string): string | undefined {
35
- const v = flags[key];
36
- return typeof v === "string" ? v : undefined;
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,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
+ }
@@ -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,107 @@
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
+ 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
+ function mcpInputSchema<TSchema extends v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>>(
26
+ schema: TSchema,
27
+ ) {
28
+ const convert = () => toJsonSchema(schema);
29
+
30
+ return {
31
+ ...schema,
32
+ "~standard": {
33
+ ...schema["~standard"],
34
+ jsonSchema: { input: convert, output: convert },
35
+ },
36
+ };
37
+ }
38
+
39
+ /** Expose the shared Thread workflows to a standard Codex session. */
40
+ export function createCodexMcpServer(home = cueloopHome()): McpServer {
41
+ const server = new McpServer({ name: "cueloop", version: CLI_VERSION });
42
+ const sessions = createCodexSessionRegistry(home);
43
+
44
+ server.registerTool(
45
+ "open_thread",
46
+ {
47
+ description:
48
+ "Submit or revise a cueloop Thread for plan, reply, prototype, diff, review, or refine.",
49
+ inputSchema: mcpInputSchema(OpenThreadInputSchema),
50
+ },
51
+ async (input) => {
52
+ try {
53
+ if (!sessions.authorized(input.harnessSessionId, input.cwd, input.hookToken)) {
54
+ throw new Error(
55
+ "cueloop Codex session is not authorized; trust the plugin hooks and restart Codex",
56
+ );
57
+ }
58
+ const { hookToken: _hookToken, ...threadInput } = input;
59
+ const result = await runHarnessBridge(
60
+ { operation: "open", harness: "codex", ...threadInput },
61
+ home,
62
+ );
63
+
64
+ if (result.operation === "open") startCodexDeliveryWorker(home);
65
+
66
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
67
+ } catch (error) {
68
+ return { isError: true, content: [{ type: "text", text: String(error) }] };
69
+ }
70
+ },
71
+ );
72
+ server.registerTool(
73
+ "refine_corpus",
74
+ {
75
+ description: "Analyze past cueloop Threads before proposing a refine writeback.",
76
+ inputSchema: mcpInputSchema(RefineCorpusInputSchema),
77
+ },
78
+ async () => {
79
+ try {
80
+ const result = await runHarnessBridge({ operation: "refine" }, home);
81
+
82
+ return {
83
+ content: [{ type: "text", text: result.operation === "refine" ? result.report : "" }],
84
+ };
85
+ } catch (error) {
86
+ return { isError: true, content: [{ type: "text", text: String(error) }] };
87
+ }
88
+ },
89
+ );
90
+
91
+ return server;
92
+ }
93
+
94
+ /** Keep the MCP transport and durable Codex Message poller alive together. */
95
+ export async function codexMcpCommand(): Promise<number> {
96
+ const delivery = createCodexDeliveryService();
97
+
98
+ delivery.start();
99
+ const server = serveStdio(() => createCodexMcpServer(), {
100
+ onerror: (error) => console.error(`cueloop Codex MCP: ${String(error)}`),
101
+ });
102
+ await new Promise<void>((resolve) => process.stdin.once("end", resolve));
103
+ delivery.stop();
104
+ await server.close();
105
+
106
+ return 0;
107
+ }
@@ -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
+ }