@cueloop/client 0.1.0-alpha.110 → 0.1.0-alpha.112

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/client",
3
- "version": "0.1.0-alpha.110",
3
+ "version": "0.1.0-alpha.112",
4
4
  "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -27,10 +27,10 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@cueloop/daemon": "0.1.0-alpha.110",
31
- "@cueloop/extension-api": "0.1.0-alpha.110",
32
- "@cueloop/integration-obsidian": "0.1.0-alpha.110",
33
- "@cueloop/schema": "0.1.0-alpha.110",
30
+ "@cueloop/daemon": "0.1.0-alpha.112",
31
+ "@cueloop/extension-api": "0.1.0-alpha.112",
32
+ "@cueloop/integration-obsidian": "0.1.0-alpha.112",
33
+ "@cueloop/schema": "0.1.0-alpha.112",
34
34
  "@opentui/core": "^0.6.8",
35
35
  "@opentui/keymap": "0.6.8",
36
36
  "@opentui/react": "^0.6.8",
@@ -24,6 +24,7 @@ import {
24
24
  press,
25
25
  pressKey,
26
26
  typeText,
27
+ captureSettledFrame,
27
28
  waitForState,
28
29
  waitForText,
29
30
  waitForTextGone,
@@ -212,7 +213,7 @@ describe("the four-pane workbench", () => {
212
213
  await waitForState(setup, () => diffToggleColumn(setup) === -1);
213
214
  await setup.mockMouse.click(railToggleColumn(setup), HEADER_ROW);
214
215
 
215
- expect(changedFileVisibleInRightTree(setup.captureCharFrame(), "store.ts")).toBe(false);
216
+ expect(changedFileVisibleInRightTree(await captureSettledFrame(setup), "store.ts")).toBe(false);
216
217
  setup.renderer.destroy();
217
218
  });
218
219
 
@@ -661,6 +661,23 @@ describe("the /lgtm default quick action", () => {
661
661
  });
662
662
  });
663
663
 
664
+ test("herdr ACP defaults off and requires a boolean experimental TOML flag", () => {
665
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-config-"));
666
+ const path = join(dir, "config.toml");
667
+
668
+ try {
669
+ expect(loadConfig({ userConfigPath: path }).experimental.herdrAcp).toBe(false);
670
+ writeFileSync(path, "[experimental]\nherdr_acp = true\n");
671
+ expect(loadConfig({ userConfigPath: path }).experimental.herdrAcp).toBe(true);
672
+ writeFileSync(path, "[experimental]\nherdr_acp = true\nthread_agent = true\n");
673
+ expect(loadConfig({ userConfigPath: path }).experimental.threadAgent).toBe(true);
674
+ writeFileSync(path, '[experimental]\nherdr_acp = "true"\n');
675
+ expect(loadConfig({ userConfigPath: path }).experimental.herdrAcp).toBe(false);
676
+ } finally {
677
+ rmSync(dir, { recursive: true, force: true });
678
+ }
679
+ });
680
+
664
681
  test("Thread agents default off and require a boolean experimental TOML flag", () => {
665
682
  const dir = mkdtempSync(join(tmpdir(), "cueloop-agent-config-"));
666
683
  const path = join(dir, "config.toml");
@@ -142,6 +142,7 @@ export interface CueloopConfig {
142
142
  export interface ExperimentalConfig {
143
143
  prototypePixels: boolean;
144
144
  threadAgent: boolean;
145
+ herdrAcp: boolean;
145
146
  }
146
147
 
147
148
  /** Where a display name came from: typed by the user, or verified from a GitHub login. */
@@ -264,6 +265,7 @@ const IntegrationsSchema = v.object({ obsidian: v.optional(ObsidianSchema) });
264
265
  const ExperimentalSchema = v.object({
265
266
  prototype_pixels: v.fallback(v.optional(v.boolean()), undefined),
266
267
  thread_agent: v.fallback(v.optional(v.boolean()), undefined),
268
+ herdr_acp: v.fallback(v.optional(v.boolean()), undefined),
267
269
  });
268
270
  const ReviewSchema = v.object({
269
271
  skill: v.fallback(v.optional(v.string()), undefined),
@@ -452,7 +454,7 @@ export function loadConfig(
452
454
  fx: { ...THREAD_MODEL_DEFAULTS.fx },
453
455
  },
454
456
  integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
455
- experimental: { prototypePixels: false, threadAgent: false },
457
+ experimental: { prototypePixels: false, threadAgent: false, herdrAcp: false },
456
458
  };
457
459
  // Theme name and per-token overrides are separate concerns, composed once
458
460
  // after all layers: the last file to set [ui] theme wins, and every [theme]
@@ -700,6 +702,8 @@ function applyExperimental(
700
702
  if (raw.prototype_pixels !== undefined) config.prototypePixels = raw.prototype_pixels;
701
703
 
702
704
  if (raw.thread_agent !== undefined) config.threadAgent = raw.thread_agent;
705
+
706
+ if (raw.herdr_acp !== undefined) config.herdrAcp = raw.herdr_acp;
703
707
  }
704
708
 
705
709
  /** The preferred harness affects new Threads; existing Threads require a handoff. */
@@ -27,6 +27,7 @@ export interface PtyForkOptions {
27
27
  rows?: number;
28
28
  cwd?: string;
29
29
  env?: Record<string, string>;
30
+ readPollMaxMs?: number;
30
31
  }
31
32
 
32
33
  export interface ExitEvent {
@@ -127,6 +128,7 @@ class Pty implements IPty {
127
128
  private processId: number;
128
129
  private columns: number;
129
130
  private rowCount: number;
131
+ private readonly readPollMaxMs: number;
130
132
  private closing = false;
131
133
  private reading = false;
132
134
  private readonly decoder = new TextDecoder("utf-8");
@@ -139,6 +141,11 @@ class Pty implements IPty {
139
141
  args: string[],
140
142
  options: PtyForkOptions,
141
143
  ) {
144
+ this.readPollMaxMs = options.readPollMaxMs ?? READ_IDLE_MAX_MS;
145
+
146
+ if (!Number.isFinite(this.readPollMaxMs) || this.readPollMaxMs < 1)
147
+ throw new Error("PTY read poll limit must be at least one millisecond");
148
+
142
149
  this.columns = options.cols ?? DEFAULT_COLS;
143
150
  this.rowCount = options.rows ?? DEFAULT_ROWS;
144
151
  const cwd = options.cwd ?? process.cwd();
@@ -236,7 +243,7 @@ class Pty implements IPty {
236
243
  this.closing = true;
237
244
  this.exitEvent.fire({ exitCode });
238
245
  } else {
239
- const delay = Math.min(READ_IDLE_MAX_MS, READ_IDLE_MS * 2 ** Math.min(idlePolls, 5));
246
+ const delay = Math.min(this.readPollMaxMs, READ_IDLE_MS * 2 ** Math.min(idlePolls, 5));
240
247
 
241
248
  idlePolls += 1;
242
249
  await new Promise((resolve) => setTimeout(resolve, delay));
@@ -308,7 +308,9 @@ async function waitForFramePredicate(
308
308
  );
309
309
  }
310
310
 
311
- const frame = setup.captureCharFrame();
311
+ // Retry while a render still holds the buffer lease.
312
+ // eslint-disable-next-line no-await-in-loop
313
+ const frame = await captureSettledFrame(setup);
312
314
 
313
315
  polls++;
314
316
 
@@ -340,6 +342,27 @@ async function waitForFramePredicate(
340
342
  }
341
343
  }
342
344
 
345
+ /**
346
+ * Capture a frame, retrying while OpenTUI still holds the buffer lease.
347
+ * A render in progress answers the capture with OutputBusy.
348
+ */
349
+ export async function captureSettledFrame(
350
+ setup: { captureCharFrame(): string },
351
+ deadline = Date.now() + 1_000,
352
+ ): Promise<string> {
353
+ try {
354
+ return setup.captureCharFrame();
355
+ } catch (error) {
356
+ const busy = error instanceof Error && error.message.includes("OutputBusy");
357
+
358
+ if (!busy || Date.now() >= deadline) throw error;
359
+
360
+ await new Promise((resolve) => setTimeout(resolve, 15));
361
+
362
+ return captureSettledFrame(setup, deadline);
363
+ }
364
+ }
365
+
343
366
  /** Wait until a state predicate holds (daemon round-trips included). */
344
367
  export async function waitForState(
345
368
  setup: TestRendererSetup,
@@ -373,7 +396,9 @@ export async function waitForState(
373
396
  );
374
397
  }
375
398
 
376
- const frame = setup.captureCharFrame();
399
+ // Retry while a render still holds the buffer lease.
400
+ // eslint-disable-next-line no-await-in-loop
401
+ const frame = await captureSettledFrame(setup);
377
402
 
378
403
  polls++;
379
404
 
@@ -479,6 +479,9 @@ class Controller implements ReviewController {
479
479
 
480
480
  this.client = client;
481
481
  client.onEvent((event) => {
482
+ // Agent transcripts have their own observer; they do not change Thread records or the inbox.
483
+ if (event.event === "agent.updated") return;
484
+
482
485
  // another controller/observer changed state: re-fetch the active session's content
483
486
  const session = this.snapshot.session;
484
487