@cueloop/daemon 0.1.0-alpha.66 → 0.1.0-alpha.68

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/daemon",
3
- "version": "0.1.0-alpha.66",
3
+ "version": "0.1.0-alpha.68",
4
4
  "description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -35,7 +35,7 @@
35
35
  "access": "public"
36
36
  },
37
37
  "dependencies": {
38
- "@cueloop/schema": "0.1.0-alpha.66",
38
+ "@cueloop/schema": "0.1.0-alpha.68",
39
39
  "@pierre/diffs": "^1.3.6",
40
40
  "valibot": "^1.4.2"
41
41
  },
package/src/api.test.ts CHANGED
@@ -154,6 +154,24 @@ describe("session lifecycle", () => {
154
154
  expect(core.sessionGet(session.id).workingCopy).toBeUndefined();
155
155
  });
156
156
 
157
+ test("a thread rename sets the title, clears back to the default on empty, and survives a restart", () => {
158
+ // Arrange
159
+ const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
160
+
161
+ // Act
162
+ core.sessionSetTitle(session.id, " Renamed thread ");
163
+
164
+ // Assert - trimmed and persisted
165
+ expect(core.sessionGet(session.id).artifact.meta.title).toBe("Renamed thread");
166
+ expect(new DaemonCore(home).sessionGet(session.id).artifact.meta.title).toBe("Renamed thread");
167
+
168
+ // Act - an empty title clears back to the derived default
169
+ core.sessionSetTitle(session.id, " ");
170
+
171
+ // Assert
172
+ expect(core.sessionGet(session.id).artifact.meta.title).toBeUndefined();
173
+ });
174
+
157
175
  test("viewed paths merge, dedupe, clear on empty, and survive a daemon restart", () => {
158
176
  // Arrange
159
177
  const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
package/src/api.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  switchBranch,
32
32
  verdictAllows,
33
33
  type Annotation,
34
+ type DiffFileStatus,
34
35
  type HunkRejection,
35
36
  type NewEntry,
36
37
  type Artifact,
@@ -46,7 +47,9 @@ import { SessionStore, withHistory } from "./store";
46
47
  import { pruneExpiredSessions, resolveCleanupPeriodDays } from "./retention";
47
48
  import { HerdrTabStore, type HerdrTabHandle } from "./herdr-tab-store";
48
49
  import { DiffWatcher } from "./diff-watcher";
49
- import { workingTreeDiff } from "./working-tree";
50
+ import { workingTreeDiff, workingChangeList } from "./working-tree";
51
+ import { listProjectFiles, readProjectFile } from "./project-files";
52
+ import { resolveWorkspace } from "./review";
50
53
  import { DaemonError } from "./errors";
51
54
 
52
55
  /** What a share hands back: the notes and names it collected, and the removals it recorded. */
@@ -321,6 +324,45 @@ export class DaemonCore {
321
324
  return session;
322
325
  }
323
326
 
327
+ /** Rename a session's display title; an empty title clears it back to the derived default. */
328
+ sessionSetTitle(id: string, title: string): ReviewSession {
329
+ const session = this.mutable(id);
330
+ const trimmed = title.trim();
331
+
332
+ if (trimmed.length === 0) delete session.artifact.meta.title;
333
+ else session.artifact.meta.title = trimmed;
334
+ this.store.upsert(session);
335
+ // the title shows in the Threads sidebar, so a rename is an inbox change, not just a content edit
336
+ this.emit("inbox.changed", id);
337
+
338
+ return session;
339
+ }
340
+
341
+ /** Tracked, repo-relative file paths for the session's workspace; [] when it has no repo. */
342
+ projectFiles(id: string): Promise<string[]> {
343
+ return listProjectFiles(this.sessionGet(id).workspace.repoRoot);
344
+ }
345
+
346
+ /** UTF-8 contents of a repo-relative file in the session's workspace, or null when it cannot be read safely. */
347
+ fileContents(id: string, path: string): Promise<string | null> {
348
+ return readProjectFile(this.sessionGet(id).workspace.repoRoot, path);
349
+ }
350
+
351
+ /** Tracked, repo-relative file paths for the git repo containing `cwd`; [] when it is not a repo. Owner-only. */
352
+ async repoFiles(cwd: string): Promise<string[]> {
353
+ return listProjectFiles((await resolveWorkspace(cwd)).repoRoot);
354
+ }
355
+
356
+ /** UTF-8 contents of a repo-relative file in the repo containing `cwd`, or null when unreadable. Owner-only. */
357
+ async repoFileContents(cwd: string, path: string): Promise<string | null> {
358
+ return readProjectFile((await resolveWorkspace(cwd)).repoRoot, path);
359
+ }
360
+
361
+ /** Changed files (repo-relative path plus git status) in the working tree at `cwd`. Owner-only. */
362
+ repoChanges(cwd: string): Promise<{ path: string; status: DiffFileStatus }[]> {
363
+ return workingChangeList(cwd);
364
+ }
365
+
324
366
  sessionSetShareId(id: string, shareId: string): ReviewSession {
325
367
  const session = this.mutable(id);
326
368
 
@@ -35,6 +35,14 @@ const PRIMITIVE_ROLES = {
35
35
  "session.restoreBlock": "owner",
36
36
  "session.curate": "owner",
37
37
  "session.setViewed": "owner",
38
+ "session.setTitle": "owner",
39
+ // reading the local working tree is the owner's alone; a share carries no repo to read
40
+ "session.projectFiles": "owner",
41
+ "session.fileContents": "owner",
42
+ // the launch repo is the local owner's; a share has no local repo to browse
43
+ "repo.files": "owner",
44
+ "repo.fileContents": "owner",
45
+ "repo.changes": "owner",
38
46
  // the tree is the owner's: collaborators comment on the branch a share follows
39
47
  "session.navigate": "owner",
40
48
  "session.branch": "owner",
package/src/client.ts CHANGED
@@ -9,6 +9,7 @@ import * as v from "valibot";
9
9
  import type {
10
10
  Annotation,
11
11
  Artifact,
12
+ DiffFileStatus,
12
13
  HunkRejection,
13
14
  ReviewSession,
14
15
  VerdictKind,
@@ -78,6 +79,18 @@ export interface SessionClient {
78
79
  /** Replace a diff review's reject decisions; the working copy follows. */
79
80
  sessionCurate(id: string, rejections: HunkRejection[]): Promise<ReviewSession>;
80
81
  sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession>;
82
+ /** Rename a session's display title; an empty title restores the derived default. */
83
+ sessionSetTitle(id: string, title: string): Promise<ReviewSession>;
84
+ /** Tracked, repo-relative file paths for the session's workspace; a client with no local repo (a share) omits it. */
85
+ projectFiles?(sessionId: string): Promise<string[]>;
86
+ /** UTF-8 contents of a repo-relative file, or null when it cannot be read safely; omitted by a client with no local repo. */
87
+ fileContents?(sessionId: string, path: string): Promise<string | null>;
88
+ /** Tracked, repo-relative paths for the git repo containing `cwd`, for the no-session welcome shell. */
89
+ repoFiles?(cwd: string): Promise<string[]>;
90
+ /** UTF-8 contents of a repo-relative file in `cwd`'s repo, or null when unreadable. */
91
+ repoFileContents?(cwd: string, path: string): Promise<string | null>;
92
+ /** Changed files (path plus git status) in the working tree at `cwd`. */
93
+ repoChanges?(cwd: string): Promise<{ path: string; status: DiffFileStatus }[]>;
81
94
  /** Move a branch's tip (the current one, or `branch` after switching to it) back to an entry on its path; a summary records the abandoned segment. */
82
95
  sessionNavigate(
83
96
  id: string,
@@ -365,6 +378,28 @@ export class DaemonClient implements SessionClient {
365
378
  sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession> {
366
379
  return this.request("session.setViewed", { id, viewedPaths }, SessionRecordSchema);
367
380
  }
381
+ sessionSetTitle(id: string, title: string): Promise<ReviewSession> {
382
+ return this.request("session.setTitle", { id, title }, SessionRecordSchema);
383
+ }
384
+ projectFiles(sessionId: string): Promise<string[]> {
385
+ return this.request("session.projectFiles", { id: sessionId }, v.array(v.string()));
386
+ }
387
+ fileContents(sessionId: string, path: string): Promise<string | null> {
388
+ return this.request("session.fileContents", { id: sessionId, path }, v.nullable(v.string()));
389
+ }
390
+ repoFiles(cwd: string): Promise<string[]> {
391
+ return this.request("repo.files", { cwd }, v.array(v.string()));
392
+ }
393
+ repoFileContents(cwd: string, path: string): Promise<string | null> {
394
+ return this.request("repo.fileContents", { cwd, path }, v.nullable(v.string()));
395
+ }
396
+ repoChanges(cwd: string): Promise<{ path: string; status: DiffFileStatus }[]> {
397
+ return this.request(
398
+ "repo.changes",
399
+ { cwd },
400
+ v.array(v.object({ path: v.string(), status: v.picklist(["added", "modified", "deleted"]) })),
401
+ );
402
+ }
368
403
  /** Re-capture a diff session's working tree; changed=true when the patch moved and an event fired. */
369
404
  sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
370
405
  return this.request("session.refreshDiff", { id }, RefreshDiffResultSchema);
@@ -0,0 +1,124 @@
1
+ /** Read-only repo introspection: the tracked file tree and safe, bounded file reads. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { listProjectFiles, readProjectFile } from "./project-files";
8
+
9
+ function git(args: string[], cwd: string): void {
10
+ const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "ignore", stderr: "ignore" });
11
+
12
+ if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed in ${cwd}`);
13
+ }
14
+
15
+ let repo: string;
16
+
17
+ beforeEach(() => {
18
+ repo = mkdtempSync(join(tmpdir(), "cueloop-project-files-"));
19
+ git(["init", "-q", "-b", "main"], repo);
20
+ git(["config", "user.email", "t@t"], repo);
21
+ git(["config", "user.name", "t"], repo);
22
+ });
23
+
24
+ afterEach(() => {
25
+ rmSync(repo, { recursive: true, force: true });
26
+ });
27
+
28
+ describe("listProjectFiles", () => {
29
+ test("returns the tracked files sorted ascending", async () => {
30
+ // Given a repo whose files are committed out of alphabetical order
31
+ writeFileSync(join(repo, "z.ts"), "export const z = 1;\n");
32
+ writeFileSync(join(repo, "a.ts"), "export const a = 1;\n");
33
+ writeFileSync(join(repo, "m.ts"), "export const m = 1;\n");
34
+ git(["add", "."], repo);
35
+ git(["commit", "-qm", "init"], repo);
36
+
37
+ // When the tracked files are listed
38
+ const files = await listProjectFiles(repo);
39
+
40
+ // Then every tracked path comes back in ascending order
41
+ expect(files).toEqual(["a.ts", "m.ts", "z.ts"]);
42
+ });
43
+
44
+ test("returns [] for a directory that is not a git repo", async () => {
45
+ // Given a plain directory with a file but no git repo
46
+ const plain = mkdtempSync(join(tmpdir(), "cueloop-project-files-plain-"));
47
+
48
+ writeFileSync(join(plain, "a.ts"), "export const a = 1;\n");
49
+ try {
50
+ // When the tracked files are listed
51
+ const files = await listProjectFiles(plain);
52
+
53
+ // Then nothing comes back and no error is thrown
54
+ expect(files).toEqual([]);
55
+ } finally {
56
+ rmSync(plain, { recursive: true, force: true });
57
+ }
58
+ });
59
+
60
+ test("returns [] when there is no repoRoot", async () => {
61
+ expect(await listProjectFiles(undefined)).toEqual([]);
62
+ });
63
+ });
64
+
65
+ describe("readProjectFile", () => {
66
+ test("reads a repo-relative file as UTF-8", async () => {
67
+ // Given a committed file with known contents
68
+ writeFileSync(join(repo, "a.ts"), "export const a = 1;\n");
69
+ git(["add", "."], repo);
70
+ git(["commit", "-qm", "init"], repo);
71
+
72
+ // When it is read by its repo-relative path
73
+ const contents = await readProjectFile(repo, "a.ts");
74
+
75
+ // Then its exact bytes come back
76
+ expect(contents).toBe("export const a = 1;\n");
77
+ });
78
+
79
+ test("returns null for a path that escapes the repo via ../", async () => {
80
+ // Given a secret file just outside the repo root
81
+ const secret = join(repo, "..", "outside-secret.txt");
82
+
83
+ writeFileSync(secret, "top secret\n");
84
+ try {
85
+ // When a traversal path tries to reach it
86
+ const contents = await readProjectFile(repo, "../outside-secret.txt");
87
+
88
+ // Then the read is refused
89
+ expect(contents).toBeNull();
90
+ } finally {
91
+ rmSync(secret, { force: true });
92
+ }
93
+ });
94
+
95
+ test("returns null for a missing file", async () => {
96
+ expect(await readProjectFile(repo, "nope.ts")).toBeNull();
97
+ });
98
+
99
+ test("returns null for a directory (not a regular file)", async () => {
100
+ // Given a directory inside the repo
101
+ mkdirSync(join(repo, "src"));
102
+
103
+ // When the directory path is read
104
+ const contents = await readProjectFile(repo, "src");
105
+
106
+ // Then it is refused
107
+ expect(contents).toBeNull();
108
+ });
109
+
110
+ test("returns null for a file larger than the cap", async () => {
111
+ // Given a file just over one megabyte
112
+ writeFileSync(join(repo, "big.bin"), "x".repeat(1024 * 1024 + 1));
113
+
114
+ // When it is read
115
+ const contents = await readProjectFile(repo, "big.bin");
116
+
117
+ // Then it is refused for exceeding the size cap
118
+ expect(contents).toBeNull();
119
+ });
120
+
121
+ test("returns null when there is no repoRoot", async () => {
122
+ expect(await readProjectFile(undefined, "a.ts")).toBeNull();
123
+ });
124
+ });
@@ -0,0 +1,56 @@
1
+ /** Read-only repo introspection for the Project panel: the tracked file tree and a file's contents. */
2
+
3
+ import { stat } from "node:fs/promises";
4
+ import { isAbsolute, relative, resolve, sep } from "node:path";
5
+
6
+ /** Ceiling on the returned tree; a pathological repo cannot flood the panel or the wire. */
7
+ const MAX_FILES = 5000;
8
+ /** Ceiling on a served file; the panel renders source, not multi-megabyte blobs. */
9
+ const MAX_FILE_BYTES = 1024 * 1024;
10
+
11
+ /** Tracked, repo-relative paths from `git ls-files`, sorted ascending and capped; [] on any failure or non-repo. */
12
+ export async function listProjectFiles(repoRoot: string | undefined): Promise<string[]> {
13
+ if (repoRoot === undefined || repoRoot.length === 0) return [];
14
+ try {
15
+ const gitProcess = Bun.spawn(["git", "-C", repoRoot, "ls-files", "-z"], {
16
+ stdout: "pipe",
17
+ stderr: "ignore",
18
+ });
19
+ const stdout = await new Response(gitProcess.stdout).text();
20
+
21
+ if ((await gitProcess.exited) !== 0) return [];
22
+ const paths = stdout.split("\0").filter((path) => path.length > 0);
23
+
24
+ return paths.toSorted().slice(0, MAX_FILES);
25
+ } catch {
26
+ return [];
27
+ }
28
+ }
29
+
30
+ /** True when `candidate` resolves outside `root` (path traversal) or is `root` itself. */
31
+ function escapesRoot(root: string, candidate: string): boolean {
32
+ const rel = relative(root, candidate);
33
+
34
+ return rel.length === 0 || rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel);
35
+ }
36
+
37
+ /** UTF-8 contents of `repoRoot/path`, or null: no repo, traversal escape, not a regular file, oversized, or any error. */
38
+ export async function readProjectFile(
39
+ repoRoot: string | undefined,
40
+ path: string,
41
+ ): Promise<string | null> {
42
+ if (repoRoot === undefined || repoRoot.length === 0) return null;
43
+ const root = resolve(repoRoot);
44
+ const target = resolve(root, path);
45
+
46
+ if (escapesRoot(root, target)) return null;
47
+ try {
48
+ const stats = await stat(target);
49
+
50
+ if (!stats.isFile() || stats.size > MAX_FILE_BYTES) return null;
51
+
52
+ return await Bun.file(target).text();
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
package/src/server.ts CHANGED
@@ -380,6 +380,27 @@ export class DaemonServer {
380
380
 
381
381
  return this.core.sessionSetViewed(params.id, params.viewedPaths);
382
382
  },
383
+ "session.setTitle": (_connection, request) => {
384
+ const params = parseParams("session.setTitle", request.params);
385
+
386
+ return this.core.sessionSetTitle(params.id, params.title);
387
+ },
388
+ "session.projectFiles": (_connection, request) =>
389
+ this.core.projectFiles(parseParams("session.projectFiles", request.params).id),
390
+ "session.fileContents": (_connection, request) => {
391
+ const params = parseParams("session.fileContents", request.params);
392
+
393
+ return this.core.fileContents(params.id, params.path);
394
+ },
395
+ "repo.files": (_connection, request) =>
396
+ this.core.repoFiles(parseParams("repo.files", request.params).cwd),
397
+ "repo.fileContents": (_connection, request) => {
398
+ const params = parseParams("repo.fileContents", request.params);
399
+
400
+ return this.core.repoFileContents(params.cwd, params.path);
401
+ },
402
+ "repo.changes": (_connection, request) =>
403
+ this.core.repoChanges(parseParams("repo.changes", request.params).cwd),
383
404
  "session.refreshDiff": (_connection, request) => {
384
405
  const params = parseParams("session.refreshDiff", request.params);
385
406
 
package/src/validate.ts CHANGED
@@ -166,6 +166,12 @@ export const Params = {
166
166
  ),
167
167
  }),
168
168
  "session.setViewed": v.object({ id: SessionId, viewedPaths: v.array(v.string()) }),
169
+ "session.setTitle": v.object({ id: SessionId, title: v.string() }),
170
+ "session.projectFiles": v.object({ id: SessionId }),
171
+ "session.fileContents": v.object({ id: SessionId, path: NonEmpty }),
172
+ "repo.files": v.object({ cwd: NonEmpty }),
173
+ "repo.fileContents": v.object({ cwd: NonEmpty, path: NonEmpty }),
174
+ "repo.changes": v.object({ cwd: NonEmpty }),
169
175
  "session.navigate": v.object({
170
176
  id: SessionId,
171
177
  entryId: NonEmpty,
@@ -1,7 +1,7 @@
1
1
  /** Working-tree diff capture for `cueloop diff`, untracked files included. */
2
2
 
3
3
  import { join } from "node:path";
4
- import type { DiffFileContents } from "@cueloop/schema";
4
+ import type { DiffFileContents, DiffFileStatus } from "@cueloop/schema";
5
5
 
6
6
  async function git(args: string[], cwd: string): Promise<string | null> {
7
7
  const gitProcess = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
@@ -12,6 +12,42 @@ async function git(args: string[], cwd: string): Promise<string | null> {
12
12
  return stdout.trim();
13
13
  }
14
14
 
15
+ /** Map a `git diff --name-status` letter to a diff status; renames/copies read as modified. */
16
+ function statusFromCode(code: string): DiffFileStatus {
17
+ const letter = code.charAt(0);
18
+ if (letter === "D") return "deleted";
19
+ if (letter === "A") return "added";
20
+
21
+ return "modified";
22
+ }
23
+
24
+ /**
25
+ * The working tree's changed files as repo-relative path plus status - a fast listing for the changes
26
+ * navigator that never reads file contents (unlike workingTreeDiff, which captures full curation copies).
27
+ * Uses tab-separated name-status so a trimmed leading column can never shift the parse.
28
+ */
29
+ export async function workingChangeList(
30
+ cwd = process.cwd(),
31
+ ): Promise<{ path: string; status: DiffFileStatus }[]> {
32
+ const changes: { path: string; status: DiffFileStatus }[] = [];
33
+ const tracked = (await git(["diff", "--name-status", "HEAD"], cwd)) ?? "";
34
+
35
+ for (const line of tracked.split("\n")) {
36
+ if (line.trim().length === 0) continue;
37
+ const parts = line.split("\t");
38
+ // a rename is "R100<tab>old<tab>new"; the new path (last field) is what the reviewer opens
39
+ changes.push({ path: parts[parts.length - 1]!, status: statusFromCode(parts[0]!) });
40
+ }
41
+
42
+ const untracked = (await git(["ls-files", "--others", "--exclude-standard"], cwd)) ?? "";
43
+
44
+ for (const path of untracked.split("\n")) {
45
+ if (path.trim().length > 0) changes.push({ path, status: "added" });
46
+ }
47
+
48
+ return changes;
49
+ }
50
+
15
51
  /** The blob at HEAD for a path, or "" when the path is not in HEAD (new file). */
16
52
  async function headContents(path: string, cwd: string): Promise<string> {
17
53
  const gitProcess = Bun.spawn(["git", "show", `HEAD:${path}`], {