cueloop 0.1.0-alpha.71 → 0.1.0-alpha.73

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,6 +1,6 @@
1
1
  {
2
2
  "name": "cueloop",
3
- "version": "0.1.0-alpha.71",
3
+ "version": "0.1.0-alpha.73",
4
4
  "description": "Terminal review surface for coding agents: annotate plans and diffs, return a verdict, send structured feedback back",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -30,10 +30,10 @@
30
30
  "build:binary": "bun build --compile --external cpu-features ./src/main.ts --outfile dist/cueloop"
31
31
  },
32
32
  "dependencies": {
33
- "@cueloop/adapters": "0.1.0-alpha.71",
34
- "@cueloop/client": "0.1.0-alpha.71",
35
- "@cueloop/daemon": "0.1.0-alpha.71",
36
- "@cueloop/schema": "0.1.0-alpha.71",
33
+ "@cueloop/adapters": "0.1.0-alpha.73",
34
+ "@cueloop/client": "0.1.0-alpha.73",
35
+ "@cueloop/daemon": "0.1.0-alpha.73",
36
+ "@cueloop/schema": "0.1.0-alpha.73",
37
37
  "valibot": "^1.4.2"
38
38
  },
39
39
  "engines": {
@@ -0,0 +1,43 @@
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 { 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
+ });
@@ -0,0 +1,108 @@
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, rmSync } 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
+ // a wedged daemon that never released its socket would otherwise be reused, not replaced
99
+ const socket = socketPath(home);
100
+
101
+ if (existsSync(socket)) rmSync(socket, { force: true });
102
+ const client = await DaemonClient.connect({ home, autostart: true });
103
+
104
+ client.close();
105
+ console.log("cueloop daemon restarted");
106
+
107
+ return 0;
108
+ }
package/src/main.ts CHANGED
@@ -105,6 +105,8 @@ interface CommandHandlers {
105
105
  const commandHandlers: CommandHandlers = {
106
106
  session: (rest) => sessionCommand(rest),
107
107
  daemon: (rest) => daemonCommand(rest),
108
+ stop: async () => (await import("./daemon-control")).stopCommand(),
109
+ restart: async () => (await import("./daemon-control")).restartCommand(),
108
110
  plan: (rest) => planCommand(rest),
109
111
  reply: (rest) => replyCommand(rest),
110
112
  diff: (rest) => diffCommand(rest),
@@ -354,6 +356,8 @@ function printHelp(): void {
354
356
  " cueloop wake <id> [--harness codex --thread <id>] resume the agent with the verdict (spawn detached)",
355
357
  " cueloop review-post <id> <pr> post a resolved session's verdict back to the PR",
356
358
  " cueloop daemon run the daemon in the foreground",
359
+ " cueloop stop stop the local daemon",
360
+ " cueloop restart stop the local daemon and start a fresh one",
357
361
  " cueloop dev open the TUI on an isolated home seeded with example threads",
358
362
  "",
359
363
  " cueloop -v, --version print the installed version",
@@ -3,16 +3,22 @@
3
3
  import { describe, expect, mock, test } from "bun:test";
4
4
  import { resolveInstallDir, runUpdate, type UpdateDeps } from "./update-command";
5
5
 
6
- function depsSpy(overrides: Partial<UpdateDeps> = {}): UpdateDeps & { lines: string[] } {
6
+ function depsSpy(
7
+ overrides: Partial<UpdateDeps> = {},
8
+ ): UpdateDeps & { lines: string[]; errorLines: string[] } {
7
9
  const lines: string[] = [];
10
+ const errorLines: string[] = [];
8
11
 
9
12
  return {
10
13
  currentVersion: "0.1.0-alpha.68",
11
14
  installDir: () => "/Users/dev/.local/bin",
12
15
  fetchLatestVersion: mock(async () => "0.1.0-alpha.68"),
13
16
  runInstaller: mock(async () => 0),
17
+ stopDaemon: mock(async () => true),
14
18
  out: (message: string) => void lines.push(message),
19
+ error: (message: string) => void errorLines.push(message),
15
20
  lines,
21
+ errorLines,
16
22
  ...overrides,
17
23
  };
18
24
  }
@@ -116,9 +122,27 @@ describe(runUpdate, () => {
116
122
  // Act
117
123
  const code = await runUpdate(deps, false);
118
124
 
119
- // Assert
125
+ // Assert - it also stops the stale-version daemon so the next launch autostarts the new build
120
126
  expect(code).toBe(0);
121
127
  expect(deps.runInstaller).toHaveBeenCalledWith("/Users/dev/.local/bin");
128
+ expect(deps.stopDaemon).toHaveBeenCalledTimes(1);
129
+ expect(deps.lines).toContain("Update ran successfully! Please restart cueloop.");
130
+ });
131
+
132
+ test("a failing daemon stop never fails an install that already succeeded", async () => {
133
+ // Arrange - the install succeeds but the cleanup stop rejects
134
+ const deps = depsSpy({
135
+ fetchLatestVersion: mock(async () => "0.1.0-alpha.99"),
136
+ stopDaemon: mock(async () => {
137
+ throw new Error("pid file vanished");
138
+ }),
139
+ });
140
+
141
+ // Act
142
+ const code = await runUpdate(deps, false);
143
+
144
+ // Assert - the update still reports success
145
+ expect(code).toBe(0);
122
146
  expect(deps.lines).toContain("Update ran successfully! Please restart cueloop.");
123
147
  });
124
148
 
@@ -143,8 +167,9 @@ describe(runUpdate, () => {
143
167
  // Act
144
168
  const code = await runUpdate(deps, false);
145
169
 
146
- // Assert
170
+ // Assert - a failed install leaves the current daemon alone
147
171
  expect(code).toBe(1);
172
+ expect(deps.stopDaemon).not.toHaveBeenCalled();
148
173
  expect(deps.lines).not.toContain("Update ran successfully! Please restart cueloop.");
149
174
  });
150
175
 
@@ -155,9 +180,24 @@ describe(runUpdate, () => {
155
180
  // Act
156
181
  const code = await runUpdate(deps, false);
157
182
 
183
+ // Assert - the failure goes to stderr, and no progress prints to stdout
184
+ expect(code).toBe(1);
185
+ expect(deps.errorLines[0]).toContain("must be an absolute path");
186
+ expect(deps.lines).toHaveLength(0);
187
+ expect(deps.runInstaller).not.toHaveBeenCalled();
188
+ });
189
+
190
+ test("reports an unresolvable install dir on stderr with no stdout progress", async () => {
191
+ // Arrange - neither an override, the binary path, nor HOME resolves a target
192
+ const deps = depsSpy({ installDir: () => undefined });
193
+
194
+ // Act
195
+ const code = await runUpdate(deps, false);
196
+
158
197
  // Assert
159
198
  expect(code).toBe(1);
160
- expect(deps.lines[0]).toContain("must be an absolute path");
199
+ expect(deps.errorLines[0]).toContain("HOME is not set");
200
+ expect(deps.lines).toHaveLength(0);
161
201
  expect(deps.runInstaller).not.toHaveBeenCalled();
162
202
  });
163
203
  });
@@ -1,6 +1,7 @@
1
1
  import { basename, dirname, isAbsolute, resolve } from "node:path";
2
2
  import * as v from "valibot";
3
3
  import { CLI_VERSION } from "./version";
4
+ import { stopDaemon } from "./daemon-control";
4
5
 
5
6
  const INSTALL_URL = "https://cueloop.dev/install.sh";
6
7
  const RELEASES_URL = "https://api.github.com/repos/mmurakaru/cueloop/releases?per_page=100";
@@ -117,7 +118,12 @@ export interface UpdateDeps {
117
118
  installDir: () => string | undefined;
118
119
  fetchLatestVersion: () => Promise<string | undefined>;
119
120
  runInstaller: (targetInstallDir: string) => Promise<number>;
121
+ /** Stop the running daemon after an update so the next launch autostarts the new build; resolves true when one was stopped. */
122
+ stopDaemon: () => Promise<boolean>;
123
+ /** Progress and status, on stdout - not an error, so it must not read as one. */
120
124
  out: (message: string) => void;
125
+ /** A failure that ends the run, on stderr. */
126
+ error: (message: string) => void;
121
127
  }
122
128
 
123
129
  /**
@@ -130,12 +136,12 @@ export async function runUpdate(deps: UpdateDeps, dryRun: boolean): Promise<numb
130
136
  const targetInstallDir = deps.installDir();
131
137
 
132
138
  if (targetInstallDir === undefined) {
133
- deps.out("cueloop update: HOME is not set and the current binary path is unknown");
139
+ deps.error("cueloop update: HOME is not set and the current binary path is unknown");
134
140
 
135
141
  return 1;
136
142
  }
137
143
  if (!targetInstallDir.startsWith("/")) {
138
- deps.out("cueloop update: CUELOOP_INSTALL_DIR must be an absolute path");
144
+ deps.error("cueloop update: CUELOOP_INSTALL_DIR must be an absolute path");
139
145
 
140
146
  return 1;
141
147
  }
@@ -157,7 +163,14 @@ export async function runUpdate(deps: UpdateDeps, dryRun: boolean): Promise<numb
157
163
  deps.out(`updating cueloop in ${targetInstallDir}...`);
158
164
  const exitCode = await deps.runInstaller(targetInstallDir);
159
165
 
160
- if (exitCode === 0) deps.out("Update ran successfully! Please restart cueloop.");
166
+ if (exitCode === 0) {
167
+ try {
168
+ await deps.stopDaemon();
169
+ } catch {
170
+ // stopping the old daemon is cleanup; it must never fail an install that already succeeded
171
+ }
172
+ deps.out("Update ran successfully! Please restart cueloop.");
173
+ }
161
174
 
162
175
  return exitCode;
163
176
  }
@@ -170,7 +183,9 @@ export async function updateCommand(argv: string[] = []): Promise<number> {
170
183
  installDir: () => resolveInstallDir(process.execPath, process.env),
171
184
  fetchLatestVersion,
172
185
  runInstaller,
173
- out: (message) => console.error(message),
186
+ stopDaemon: () => stopDaemon(),
187
+ out: (message) => console.log(message),
188
+ error: (message) => console.error(message),
174
189
  },
175
190
  argv.includes("--dry-run"),
176
191
  );