cueloop 0.1.0-alpha.72 → 0.1.0-alpha.74

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.72",
3
+ "version": "0.1.0-alpha.74",
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.72",
34
- "@cueloop/client": "0.1.0-alpha.72",
35
- "@cueloop/daemon": "0.1.0-alpha.72",
36
- "@cueloop/schema": "0.1.0-alpha.72",
33
+ "@cueloop/adapters": "0.1.0-alpha.74",
34
+ "@cueloop/client": "0.1.0-alpha.74",
35
+ "@cueloop/daemon": "0.1.0-alpha.74",
36
+ "@cueloop/schema": "0.1.0-alpha.74",
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
@@ -23,9 +23,8 @@ import {
23
23
  import { sessionCommand } from "./session-commands";
24
24
  import { CLI_VERSION } from "./version";
25
25
  import { DaemonClient } from "@cueloop/daemon/client";
26
- import { workingTreeDiff } from "@cueloop/daemon/working-tree";
27
26
  import type { Thread } from "@cueloop/schema";
28
- import { openReview, resolveWorkspace } from "@cueloop/daemon/review";
27
+ import { openReview } from "@cueloop/daemon/review";
29
28
 
30
29
  const argv = process.argv.slice(2);
31
30
  const cmd = argv[0];
@@ -105,6 +104,8 @@ interface CommandHandlers {
105
104
  const commandHandlers: CommandHandlers = {
106
105
  session: (rest) => sessionCommand(rest),
107
106
  daemon: (rest) => daemonCommand(rest),
107
+ stop: async () => (await import("./daemon-control")).stopCommand(),
108
+ restart: async () => (await import("./daemon-control")).restartCommand(),
108
109
  plan: (rest) => planCommand(rest),
109
110
  reply: (rest) => replyCommand(rest),
110
111
  diff: (rest) => diffCommand(rest),
@@ -165,6 +166,7 @@ async function openReviewOfKind(
165
166
  match: (session: Thread) => boolean,
166
167
  label: string,
167
168
  selector: string | undefined,
169
+ layout: "review" | "plan" | undefined,
168
170
  emptyMessage?: string,
169
171
  ): Promise<number> {
170
172
  const client = await DaemonClient.connect({ autostart: true });
@@ -177,7 +179,7 @@ async function openReviewOfKind(
177
179
  }
178
180
  const target = resolveOpenTarget(sessions, { match, selector });
179
181
 
180
- if (target.kind === "session") return runTui(target.sessionId);
182
+ if (target.kind === "session") return runTui(target.sessionId, layout);
181
183
  if (target.kind === "no-pending" && emptyMessage !== undefined) {
182
184
  console.error(emptyMessage);
183
185
 
@@ -190,7 +192,7 @@ async function openReviewOfKind(
190
192
 
191
193
  /** `cueloop plan [id|title]` - open the latest pending plan, or address one. */
192
194
  async function planCommand(argv: string[]): Promise<number> {
193
- return openReviewOfKind(isPlanReview, "plan", openSelector(parseArgs(argv)));
195
+ return openReviewOfKind(isPlanReview, "plan", openSelector(parseArgs(argv)), "plan");
194
196
  }
195
197
 
196
198
  /**
@@ -199,7 +201,7 @@ async function planCommand(argv: string[]): Promise<number> {
199
201
  * by the /cueloop:reply skill; the opener is scope-only, like plan.
200
202
  */
201
203
  async function replyCommand(argv: string[]): Promise<number> {
202
- return openReviewOfKind(isReplyReview, "reply", openSelector(parseArgs(argv)));
204
+ return openReviewOfKind(isReplyReview, "reply", openSelector(parseArgs(argv)), "plan");
203
205
  }
204
206
 
205
207
  /**
@@ -219,7 +221,7 @@ async function prototypeCommand(argv: string[]): Promise<number> {
219
221
  selector !== undefined && !isSessionId(selector) && (isHtmlPrototype || isMarkdownPrototype);
220
222
 
221
223
  if (wantsOpen || !looksLikeFile)
222
- return openReviewOfKind(isPrototypeReview, "prototype", selector);
224
+ return openReviewOfKind(isPrototypeReview, "prototype", selector, "plan");
223
225
 
224
226
  const path = resolve(selector);
225
227
  const content = await Bun.file(path)
@@ -242,47 +244,30 @@ async function prototypeCommand(argv: string[]): Promise<number> {
242
244
 
243
245
  client.close();
244
246
 
245
- return runTui(review.id);
247
+ return runTui(review.id, "plan");
246
248
  }
247
249
 
248
250
  /**
249
- * `cueloop diff` disambiguates create from open by intent:
251
+ * `cueloop diff` opens the repo's workbench in the review layout:
250
252
  * - a selector (`cueloop diff <id|title>`) or an explicit `--open`/`--latest`
251
- * opens a pending diff review;
252
- * - otherwise a dirty working tree still creates a review as before;
253
- * - a clean working tree carries no create input, so it opens the latest
254
- * pending diff review instead of erroring.
253
+ * opens a specific pending diff thread;
254
+ * - a bare `cueloop diff` find-or-creates the per-repo workbench thread - a live-diff
255
+ * annotation container - so it is immediately annotatable and always shows the
256
+ * current working tree, reusing the same thread across re-runs.
255
257
  */
256
258
  async function diffCommand(argv: string[]): Promise<number> {
257
259
  const parsed = parseArgs(argv);
258
260
  const selector = openSelector(parsed);
259
261
  const wantsOpen = selector !== undefined || "open" in parsed.flags || "latest" in parsed.flags;
260
262
 
261
- if (wantsOpen) return openReviewOfKind(isDiffReview, "diff", selector);
263
+ if (wantsOpen) return openReviewOfKind(isDiffReview, "diff", selector, "review");
262
264
 
263
- const workspace = await resolveWorkspace();
264
- const diff = await workingTreeDiff();
265
-
266
- if (!diff.patch.trim()) {
267
- return openReviewOfKind(
268
- isDiffReview,
269
- "diff",
270
- undefined,
271
- "working tree is clean and no pending diff review - nothing to open",
272
- );
273
- }
274
265
  const client = await DaemonClient.connect({ autostart: true });
275
- const review = await openReview(client, {
276
- type: "diff",
277
- content: diff.patch,
278
- files: diff.files,
279
- workspace,
280
- title: `working tree @ ${workspace.branch}`,
281
- });
266
+ const workbench = await client.sessionWorkbench(process.cwd());
282
267
 
283
268
  client.close();
284
269
 
285
- return runTui(review.id);
270
+ return runTui(workbench.id, "review");
286
271
  }
287
272
 
288
273
  /**
@@ -308,13 +293,21 @@ async function reviewEntry(argv: string[]): Promise<number> {
308
293
  isPrReview,
309
294
  "PR",
310
295
  explicitOpen || looksLikeSessionId ? selector : undefined,
296
+ "review",
311
297
  );
312
298
  }
313
299
 
314
- async function runTui(sessionId?: string): Promise<number> {
315
- const { runClient } = await import("@cueloop/client");
300
+ /**
301
+ * A create-command dictates the pane composition it opens in; a bare launch (`layout`
302
+ * omitted) restores the remembered one. Resolve the factory here so the heavy client
303
+ * module stays lazily imported for non-TUI commands.
304
+ */
305
+ async function runTui(sessionId?: string, layout?: "review" | "plan"): Promise<number> {
306
+ const { runClient, reviewLayout, planLayout } = await import("@cueloop/client");
307
+ const resolved =
308
+ layout === "review" ? reviewLayout() : layout === "plan" ? planLayout() : undefined;
316
309
 
317
- return runClient({ sessionId });
310
+ return runClient({ sessionId, layout: resolved });
318
311
  }
319
312
 
320
313
  function printHelp(): void {
@@ -354,6 +347,8 @@ function printHelp(): void {
354
347
  " cueloop wake <id> [--harness codex --thread <id>] resume the agent with the verdict (spawn detached)",
355
348
  " cueloop review-post <id> <pr> post a resolved session's verdict back to the PR",
356
349
  " cueloop daemon run the daemon in the foreground",
350
+ " cueloop stop stop the local daemon",
351
+ " cueloop restart stop the local daemon and start a fresh one",
357
352
  " cueloop dev open the TUI on an isolated home seeded with example threads",
358
353
  "",
359
354
  " cueloop -v, --version print the installed version",
package/src/pr.ts CHANGED
@@ -80,9 +80,9 @@ export async function reviewCommand(argv: string[]): Promise<number> {
80
80
  return 0;
81
81
  }
82
82
 
83
- const { runClient } = await import("@cueloop/client");
83
+ const { runClient, reviewLayout } = await import("@cueloop/client");
84
84
 
85
- await runClient({ sessionId: session.id });
85
+ await runClient({ sessionId: session.id, layout: reviewLayout() });
86
86
 
87
87
  const after = await getSession(session.id);
88
88
 
@@ -133,6 +133,43 @@ describe(shareSession, () => {
133
133
  expect(deps.lines).toEqual(["share link copied - ssh p_abc123xy@cueloop.dev"]);
134
134
  });
135
135
 
136
+ test("freezes a workbench thread's live diff into the shared artifact", async () => {
137
+ // Arrange - a workbench thread (renders live locally) plus a daemon that reports a fresh diff
138
+ const workbench = sessionFixture("ses_wb", {
139
+ artifact: {
140
+ type: "diff",
141
+ content: "STALE",
142
+ files: [],
143
+ meta: { workbench: true, title: "Workbench" },
144
+ },
145
+ });
146
+ const client = {
147
+ ...fakeClient([workbench]),
148
+ repoDiff: async () => ({
149
+ patch: "FRESH",
150
+ files: [{ path: "x.ts", oldContents: "", newContents: "y\n", status: "added" as const }],
151
+ }),
152
+ };
153
+ let published: Thread | undefined;
154
+ const deps = depsSpy({
155
+ publish: mock(async (session: Thread) => {
156
+ published = session;
157
+
158
+ return { line: "ssh p_abc123xy@cueloop.dev", copied: true };
159
+ }),
160
+ });
161
+
162
+ // Act
163
+ const code = await shareSession(client, { sessionId: "ses_wb" }, deps);
164
+
165
+ // Assert - the remote gets the fresh snapshot, marked so it renders frozen while keeping its notes
166
+ expect(code).toBe(0);
167
+ expect(published?.artifact.content).toBe("FRESH");
168
+ expect(published?.artifact.files).toHaveLength(1);
169
+ expect(published?.artifact.meta.snapshot).toBe(true);
170
+ expect(published?.artifact.meta.workbench).toBe(true);
171
+ });
172
+
136
173
  test("without an id, shares the most recent session", async () => {
137
174
  // Arrange
138
175
  const publish = mock(
@@ -12,6 +12,7 @@ import {
12
12
  publishShare,
13
13
  pullShare,
14
14
  shareIdFromLine,
15
+ snapshotWorkbench,
15
16
  type ShareResult,
16
17
  type ShareTarget,
17
18
  } from "@cueloop/client";
@@ -81,7 +82,11 @@ export async function shareSession(
81
82
  return 1;
82
83
  }
83
84
  const session = params.fork ? await client.sessionFork(picked.id) : picked;
84
- const { line, copied } = await deps.publish(session, { host: params.host, port: params.port });
85
+ // a workbench thread renders live locally; freeze its diff so the remote reviewer gets a stable snapshot
86
+ const shared = client.repoDiff
87
+ ? await snapshotWorkbench(session, client.repoDiff.bind(client))
88
+ : session;
89
+ const { line, copied } = await deps.publish(shared, { host: params.host, port: params.port });
85
90
  const shareId = shareIdFromLine(line);
86
91
 
87
92
  if (shareId) await client.sessionSetShareId(session.id, shareId);
@@ -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
  );