pi-harness-runtime 1.1.44 → 1.1.46

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.
Files changed (96) hide show
  1. package/harness/langchain/agents.ts +15 -3
  2. package/harness/langchain/daemon.ts +126 -1
  3. package/harness/langchain/file-tools.ts +178 -0
  4. package/harness/langchain/graph.ts +10 -5
  5. package/harness/langchain/status-line.test.ts +92 -0
  6. package/harness/langchain/status-line.ts +255 -0
  7. package/package.json +1 -1
  8. package/packages/a2a-adapter/package.json +1 -1
  9. package/packages/architecture-generator/package.json +1 -1
  10. package/packages/auth/package.json +1 -1
  11. package/packages/autonomous-refactor/package.json +1 -1
  12. package/packages/autonomous-runtime/package.json +1 -1
  13. package/packages/cache-strategy/package.json +1 -1
  14. package/packages/capability-registry/package.json +1 -1
  15. package/packages/checkpoint/package.json +1 -1
  16. package/packages/cli-plugin-sdk/package.json +1 -1
  17. package/packages/clipboard/package.json +1 -1
  18. package/packages/clipboard-plugin/package.json +1 -1
  19. package/packages/code-generation/package.json +1 -1
  20. package/packages/code-review/package.json +1 -1
  21. package/packages/codex-adapter/package.json +1 -1
  22. package/packages/config-capture/package.json +1 -1
  23. package/packages/context-compiler/package.json +1 -1
  24. package/packages/context-discovery/package.json +1 -1
  25. package/packages/context-manager/package.json +1 -1
  26. package/packages/cookie-sanitizer/package.json +1 -1
  27. package/packages/cost-optimizer/package.json +1 -1
  28. package/packages/dependency-analyzer/package.json +1 -1
  29. package/packages/django-plugin/package.json +1 -1
  30. package/packages/doc-generator/package.json +1 -1
  31. package/packages/evaluation-engine/package.json +1 -1
  32. package/packages/evaluation-runner/package.json +1 -1
  33. package/packages/event-bus/package.json +1 -1
  34. package/packages/event-store/package.json +1 -1
  35. package/packages/experience-replay/package.json +1 -1
  36. package/packages/feedback-collector/package.json +1 -1
  37. package/packages/file-copy-helper/package.json +1 -1
  38. package/packages/framework-detector/package.json +1 -1
  39. package/packages/framework-plugin-sdk/package.json +1 -1
  40. package/packages/frappe-plugin/package.json +1 -1
  41. package/packages/generic-web-plugin/package.json +1 -1
  42. package/packages/health-monitor/package.json +1 -1
  43. package/packages/intent-analyzer/package.json +1 -1
  44. package/packages/knowledge-graph/package.json +1 -1
  45. package/packages/knowledge-retrieval/package.json +1 -1
  46. package/packages/laravel-plugin/package.json +1 -1
  47. package/packages/learning-engine/package.json +1 -1
  48. package/packages/mcp-adapter/package.json +1 -1
  49. package/packages/memory-engine/package.json +1 -1
  50. package/packages/milestone-manager/package.json +1 -1
  51. package/packages/model-registry/package.json +1 -1
  52. package/packages/nextjs-plugin/package.json +1 -1
  53. package/packages/notification/package.json +1 -1
  54. package/packages/observability/package.json +1 -1
  55. package/packages/okf-indexer/package.json +1 -1
  56. package/packages/performance-optimizer/package.json +1 -1
  57. package/packages/privilege-broker/package.json +1 -1
  58. package/packages/project-analyzer/package.json +1 -1
  59. package/packages/project-bootstrap/package.json +1 -1
  60. package/packages/projection-engine/package.json +1 -1
  61. package/packages/prompt-compiler/package.json +1 -1
  62. package/packages/prompt-versioning/package.json +1 -1
  63. package/packages/provider-adapter-sdk/package.json +1 -1
  64. package/packages/provider-router/package.json +1 -1
  65. package/packages/provider-selector/package.json +1 -1
  66. package/packages/providers/package.json +1 -1
  67. package/packages/quota-manager/package.json +1 -1
  68. package/packages/rate-limiter/package.json +1 -1
  69. package/packages/react-vite-plugin/package.json +1 -1
  70. package/packages/release-manager/package.json +1 -1
  71. package/packages/requirement-compiler/package.json +1 -1
  72. package/packages/runtime/package.json +1 -1
  73. package/packages/scheduler/package.json +1 -1
  74. package/packages/scheduler-adapter/package.json +1 -1
  75. package/packages/session/package.json +1 -1
  76. package/packages/session-api/package.json +1 -1
  77. package/packages/session-export/package.json +1 -1
  78. package/packages/shared-context/package.json +1 -1
  79. package/packages/skill-mcp-client/package.json +1 -1
  80. package/packages/skill-registry/package.json +1 -1
  81. package/packages/sprint-planner/package.json +1 -1
  82. package/packages/subscription-engine/package.json +1 -1
  83. package/packages/task-compiler/package.json +1 -1
  84. package/packages/tencentdb-memory/package.json +1 -1
  85. package/packages/tencentdb-sync/package.json +1 -1
  86. package/packages/test-data-generator/package.json +1 -1
  87. package/packages/test-generator/package.json +1 -1
  88. package/packages/todo-bd-sync/package.json +1 -1
  89. package/packages/token-estimation/package.json +1 -1
  90. package/packages/token-optimizer/package.json +1 -1
  91. package/packages/tui/package.json +1 -1
  92. package/packages/types/package.json +1 -1
  93. package/packages/workflow-events/package.json +1 -1
  94. package/packages/workspace-scanner/package.json +1 -1
  95. package/packages/worktree/package.json +1 -1
  96. package/packages/write-review/package.json +1 -1
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { createAgent, tool } from "langchain";
21
21
  import { z } from "zod";
22
+ import { fileTools } from "./file-tools.js";
22
23
  import {
23
24
  createCoderModel,
24
25
  createPlannerModel,
@@ -103,8 +104,19 @@ The loop will route automatically.`;
103
104
  const CODER_PROMPT = `You are the Coding Agent (MiniMax) in an autonomous daemon loop.
104
105
 
105
106
  You receive a plan (and possibly review comments from a previous iteration) and
106
- produce complete, runnable code. Output fenced code blocks with file-path headers.
107
- Address every review comment explicitly.
107
+ edit the actual repository files using the available tools. Address every review
108
+ comment explicitly.
109
+
110
+ Tools available:
111
+ - read_file(path): Read a file's contents before editing
112
+ - write_file(path, content, append?): Write or append to a file
113
+ - list_directory(path): List files in a directory
114
+
115
+ Workflow:
116
+ 1. Use read_file to inspect existing files
117
+ 2. Use write_file to create or update files with real code
118
+ 3. Use list_directory to explore the project structure
119
+ 4. Write real files, not code blocks — the loop will review the actual edits
108
120
 
109
121
  IMPORTANT:
110
122
  - Do NOT output "Next Steps", "Ready for review", or any waiting-for-human signal
@@ -147,7 +159,7 @@ export function createPlannerAgent(opts: ModelOptions = {}) {
147
159
  export function createCoderAgent(opts: ModelOptions = {}) {
148
160
  return createAgent({
149
161
  model: createCoderModel(opts),
150
- tools: [], // roadmap: file read/write tools for real repo edits
162
+ tools: fileTools,
151
163
  systemPrompt: CODER_PROMPT,
152
164
  });
153
165
  }
@@ -49,6 +49,7 @@ import { NotificationCenter } from "../../packages/notification/dist/notificatio
49
49
 
50
50
  import { invokeWithSurgeRetry, type SurgePolicy } from "./surge.js";
51
51
  import type { LoopWidget } from "./widget.js";
52
+ import { StatusLineManager, isPiLensAvailable } from "./status-line.js";
52
53
  import { createLoopCheckpointer } from "./checkpointer.js";
53
54
 
54
55
  import {
@@ -518,6 +519,117 @@ class BdTasksWatcher {
518
519
  }
519
520
  }
520
521
 
522
+ // ─── Cron Source ──────────────────────────────────────────────────────────────
523
+
524
+ /**
525
+ * Watches a cron-tasks directory and fires tasks on a configurable schedule.
526
+ *
527
+ * Storage layout:
528
+ * <workspace>/.cron-tasks/
529
+ * ├── every-5m/
530
+ * │ ├── task-1.json {"request": "...", "enabled": true}
531
+ * │ └── task-2.json
532
+ * ├── hourly/
533
+ * │ └── task-3.json
534
+ * └── daily/
535
+ * └── task-4.json
536
+ *
537
+ * Schedule buckets: "every-5m", "every-10m", "every-30m", "hourly", "daily", "weekly"
538
+ * Drop a JSON file into the right bucket to schedule a task.
539
+ * Set "enabled": false to pause without deleting.
540
+ */
541
+ class CronWatcher {
542
+ private readonly workspace: string;
543
+ private readonly scheduleMs: Record<string, number>;
544
+ private readonly seen: Map<string, Set<string>> = new Map();
545
+ private readonly timers: Map<string, ReturnType<typeof setTimeout>> = new Map();
546
+ private running = false;
547
+
548
+ constructor(workspace: string) {
549
+ this.workspace = workspace;
550
+ this.scheduleMs = {
551
+ "every-5m": 5 * 60_000,
552
+ "every-10m": 10 * 60_000,
553
+ "every-30m": 30 * 60_000,
554
+ hourly: 3_600_000,
555
+ daily: 86_400_000,
556
+ weekly: 7 * 86_400_000,
557
+ };
558
+ }
559
+
560
+ start(onTask: (task: TriggeredTask) => void): void {
561
+ this.running = true;
562
+ const cronDir = `${this.workspace}/.cron-tasks`;
563
+ import("node:fs").then(({ mkdirSync, existsSync }) => {
564
+ if (!existsSync(cronDir)) mkdirSync(cronDir, { recursive: true });
565
+ });
566
+ // Fire once at start
567
+ this._tickAll(onTask);
568
+ // Schedule each bucket
569
+ for (const [schedule, ms] of Object.entries(this.scheduleMs)) {
570
+ this._scheduleNext(schedule, ms, onTask);
571
+ }
572
+ }
573
+
574
+ stop(): void {
575
+ this.running = false;
576
+ for (const t of this.timers.values()) clearTimeout(t);
577
+ this.timers.clear();
578
+ }
579
+
580
+ private _scheduleNext(
581
+ schedule: string,
582
+ ms: number,
583
+ onTask: (task: TriggeredTask) => void,
584
+ ): void {
585
+ const timer = setTimeout(async () => {
586
+ if (!this.running) return;
587
+ await this._tickSchedule(schedule, onTask);
588
+ this._scheduleNext(schedule, ms, onTask);
589
+ }, ms);
590
+ this.timers.set(schedule, timer);
591
+ }
592
+
593
+ private async _tickAll(onTask: (task: TriggeredTask) => void): Promise<void> {
594
+ for (const schedule of Object.keys(this.scheduleMs)) {
595
+ await this._tickSchedule(schedule, onTask);
596
+ }
597
+ }
598
+
599
+ private async _tickSchedule(
600
+ schedule: string,
601
+ onTask: (task: TriggeredTask) => void,
602
+ ): Promise<void> {
603
+ if (!this.running) return;
604
+ const { readdirSync, readFileSync, existsSync } = await import("node:fs");
605
+ const cronDir = `${this.workspace}/.cron-tasks/${schedule}`;
606
+ if (!existsSync(cronDir)) return;
607
+
608
+ let seen = this.seen.get(schedule);
609
+ if (!seen) { seen = new Set(); this.seen.set(schedule, seen); }
610
+
611
+ let files: string[];
612
+ try { files = readdirSync(cronDir).filter((f) => f.endsWith(".json")); }
613
+ catch { return; }
614
+
615
+ for (const file of files) {
616
+ if (seen.has(file)) continue;
617
+ seen.add(file);
618
+ let taskData: { request?: string; enabled?: boolean };
619
+ try { taskData = JSON.parse(readFileSync(`${cronDir}/${file}`, "utf8")); }
620
+ catch { continue; }
621
+ if (taskData.enabled === false) continue;
622
+ if (!taskData.request) continue;
623
+ onTask({
624
+ taskId: `cron:${schedule}:${file.replace(".json", "")}`,
625
+ request: taskData.request,
626
+ source: "cron",
627
+ triggeredAt: new Date().toISOString(),
628
+ });
629
+ }
630
+ }
631
+ }
632
+
521
633
  // ─── Tunnel Health Monitor ───────────────────────────────────────────────────
522
634
 
523
635
  /**
@@ -674,7 +786,9 @@ export class LoopDaemon {
674
786
  private readonly inboxWatcher: InboxWatcher;
675
787
  private readonly busWatcher: BusWatcher;
676
788
  private readonly bdWatcher: BdTasksWatcher;
789
+ private readonly cronWatcher: CronWatcher | null;
677
790
  private readonly tunnelMonitor: TunnelMonitor | null;
791
+ private readonly statusLine: StatusLineManager | null;
678
792
  private readonly widget: LoopWidget | null;
679
793
 
680
794
  /** Currently running tasks (taskId → RunningTask) */
@@ -730,6 +844,12 @@ export class LoopDaemon {
730
844
  this.widget = this.config.widget ?? null;
731
845
  this.busWatcher = new BusWatcher(this.bus, this.config.pollMs);
732
846
  this.bdWatcher = new BdTasksWatcher(this.config.pollMs);
847
+ this.cronWatcher = this.config.sources.includes("cron")
848
+ ? new CronWatcher(this.bus.getWorkspace())
849
+ : null;
850
+ this.statusLine = isPiLensAvailable()
851
+ ? null
852
+ : new StatusLineManager(this.bus.getWorkspace());
733
853
 
734
854
  // Tunnel monitor — only active when a tunnel script is configured
735
855
  if (this.config.tunnelCommand) {
@@ -772,6 +892,7 @@ export class LoopDaemon {
772
892
  }
773
893
 
774
894
  this.tunnelMonitor?.start();
895
+ this.statusLine?.start();
775
896
 
776
897
  console.log("[daemon] Started.");
777
898
  }
@@ -784,6 +905,8 @@ export class LoopDaemon {
784
905
  this.inboxWatcher.stop();
785
906
  this.busWatcher.stop();
786
907
  this.bdWatcher.stop();
908
+ this.cronWatcher?.stop();
909
+ this.statusLine?.stop();
787
910
  this.tunnelMonitor?.stop();
788
911
 
789
912
  // Release all held leases
@@ -888,7 +1011,7 @@ export class LoopDaemon {
888
1011
  maxIterations: this.config.maxIterations,
889
1012
  blackboardDir: this.bus.getWorkspace(),
890
1013
  })
891
- : await buildRealLoopDeps({
1014
+ : await buildRealLoopDeps({
892
1015
  maxIterations: this.config.maxIterations,
893
1016
  blackboardDir: this.bus.getWorkspace(),
894
1017
  }));
@@ -977,6 +1100,8 @@ export class LoopDaemon {
977
1100
  : "max_iterations",
978
1101
  );
979
1102
  }
1103
+ const loopDone = verdict === "approved" ? "✅ finished" : verdict === "blocked" ? "⛔ blocked" : "🔄 max_iter";
1104
+ this.statusLine?.updateLoopStatus(loopDone);
980
1105
 
981
1106
  // ── Gate check after blocked verdict ────────────────────────────────
982
1107
  if (
@@ -0,0 +1,178 @@
1
+ /**
2
+ * File system tools for the coder agent — read and write files on disk.
3
+ *
4
+ * These tools let the MiniMax coder edit the actual repository instead of
5
+ * just outputting code blocks. Tools are plain Node.js fs wrappers; the
6
+ * agent receives structured JSON results it can act on.
7
+ *
8
+ * Wiki: wiki/multi-agent-langchain.md §file-tools
9
+ */
10
+
11
+ import { tool } from "langchain";
12
+ import { z } from "zod";
13
+ import { readFileSync, writeFileSync, existsSync, statSync } from "node:fs";
14
+ import { join, isAbsolute, resolve } from "node:path";
15
+
16
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
17
+
18
+ /** Resolve a path relative to repoRoot or as absolute. */
19
+ function resolvePath(path: string, repoRoot: string): string {
20
+ if (isAbsolute(path)) return path;
21
+ return resolve(repoRoot, path);
22
+ }
23
+
24
+ // ─── Tools ──────────────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Read the contents of a file.
28
+ *
29
+ * Use this before editing to see the current code.
30
+ * Returns the full file contents as a string.
31
+ */
32
+ export const readFile = tool(
33
+ async ({
34
+ path,
35
+ encoding = "utf-8",
36
+ }: {
37
+ /** Absolute path or path relative to the repository root */
38
+ path: string;
39
+ /** File encoding (default: utf-8) */
40
+ encoding?: string;
41
+ }) => {
42
+ try {
43
+ const resolved = resolvePath(path, process.cwd());
44
+ if (!existsSync(resolved)) {
45
+ return JSON.stringify({ error: `File not found: ${resolved}` });
46
+ }
47
+ const stats = statSync(resolved);
48
+ if (stats.isDirectory()) {
49
+ return JSON.stringify({ error: `Path is a directory, not a file: ${resolved}` });
50
+ }
51
+ const content = readFileSync(resolved, encoding as BufferEncoding);
52
+ return JSON.stringify({
53
+ path: resolved,
54
+ size: stats.size,
55
+ lines: content.split("\n").length,
56
+ content,
57
+ });
58
+ } catch (err) {
59
+ return JSON.stringify({
60
+ error: `Failed to read file: ${path}`,
61
+ detail: err instanceof Error ? err.message : String(err),
62
+ });
63
+ }
64
+ },
65
+ {
66
+ name: "read_file",
67
+ description:
68
+ "Read the full contents of a file from disk. " +
69
+ "Returns the file content as a string. " +
70
+ "Use this before editing a file so you know its current contents. " +
71
+ "Returns JSON with path, size, lines, and content fields.",
72
+ schema: z.object({
73
+ path: z.string().describe("Absolute path or path relative to the repository root"),
74
+ encoding: z
75
+ .enum(["utf-8", "utf-16", "ascii", "base64"])
76
+ .optional()
77
+ .describe("File encoding (default: utf-8)"),
78
+ }),
79
+ },
80
+ );
81
+
82
+ /**
83
+ * Write or overwrite a file on disk.
84
+ *
85
+ * Creates parent directories if they don't exist.
86
+ * The caller (an agent) is responsible for ensuring the content is correct.
87
+ */
88
+ export const writeFile = tool(
89
+ async ({
90
+ path,
91
+ content,
92
+ append = false,
93
+ }: {
94
+ /** Absolute path or path relative to the repository root */
95
+ path: string;
96
+ /** The content to write. Use \\n for newlines. */
97
+ content: string;
98
+ /** If true, append to the file instead of overwriting (default: false) */
99
+ append?: boolean;
100
+ }) => {
101
+ try {
102
+ const resolved = resolvePath(path, process.cwd());
103
+ const mode = append ? "a" : "w";
104
+ // Normalize content: convert literal \n sequences to actual newlines
105
+ const normalized = content.replace(/\\n/g, "\n");
106
+ writeFileSync(resolved, normalized, { encoding: "utf-8", flag: mode });
107
+ const stats = statSync(resolved);
108
+ return JSON.stringify({
109
+ ok: true,
110
+ path: resolved,
111
+ size: stats.size,
112
+ action: append ? "appended" : "written",
113
+ });
114
+ } catch (err) {
115
+ return JSON.stringify({
116
+ error: `Failed to write file: ${path}`,
117
+ detail: err instanceof Error ? err.message : String(err),
118
+ });
119
+ }
120
+ },
121
+ {
122
+ name: "write_file",
123
+ description:
124
+ "Write or overwrite a file on disk. Creates parent directories if needed. " +
125
+ "Use literal \\n in the content string for newlines. " +
126
+ "Set append=true to add to the end of an existing file. " +
127
+ "Returns JSON with ok, path, size, and action fields.",
128
+ schema: z.object({
129
+ path: z.string().describe("Absolute path or path relative to the repository root"),
130
+ content: z.string().describe("File content. Use \\n for newlines."),
131
+ append: z
132
+ .boolean()
133
+ .optional()
134
+ .describe("Append to existing file instead of overwriting (default: false)"),
135
+ }),
136
+ },
137
+ );
138
+
139
+ /**
140
+ * List files in a directory (non-recursive).
141
+ *
142
+ * Useful for discovering the project structure.
143
+ */
144
+ export const listDir = tool(
145
+ async ({ path }: { path: string }) => {
146
+ try {
147
+ const { readdirSync } = await import("node:fs");
148
+ const resolved = resolvePath(path, process.cwd());
149
+ if (!existsSync(resolved)) {
150
+ return JSON.stringify({ error: `Directory not found: ${resolved}` });
151
+ }
152
+ const entries = readdirSync(resolved, { withFileTypes: true });
153
+ const files = entries.map((e) => ({
154
+ name: e.name,
155
+ type: e.isDirectory() ? "dir" : "file",
156
+ path: join(resolved, e.name),
157
+ }));
158
+ return JSON.stringify({ path: resolved, entries: files });
159
+ } catch (err) {
160
+ return JSON.stringify({
161
+ error: `Failed to list directory: ${path}`,
162
+ detail: err instanceof Error ? err.message : String(err),
163
+ });
164
+ }
165
+ },
166
+ {
167
+ name: "list_directory",
168
+ description:
169
+ "List the contents of a directory (files and subdirectories, non-recursive). " +
170
+ "Returns JSON with path and entries array (each entry has name, type, path).",
171
+ schema: z.object({
172
+ path: z.string().describe("Absolute path or path relative to the repository root"),
173
+ }),
174
+ },
175
+ );
176
+
177
+ /** All file system tools, ready to pass to `createCoderAgent`. */
178
+ export const fileTools = [readFile, writeFile, listDir];
@@ -232,7 +232,6 @@ export async function buildRealLoopDeps(
232
232
  const _autonomous = false;
233
233
  const _directive = "";
234
234
 
235
-
236
235
  /** Inject scoreboard markdown into a user message. */
237
236
  const withScoreboard = (msg: string) => {
238
237
  const scoreboard = blackboard.toMarkdown();
@@ -278,13 +277,15 @@ export async function buildRealLoopDeps(
278
277
  }
279
278
  };
280
279
 
281
- return {
280
+ return {
282
281
  maxIterations: options.maxIterations ?? 3,
283
282
  onStep,
284
283
  plan: async (request) =>
285
284
  lastMessage(
286
285
  await planner.invoke({
287
- messages: [{ role: "user", content: _directive + withScoreboard(request) }],
286
+ messages: [
287
+ { role: "user", content: _directive + withScoreboard(request) },
288
+ ],
288
289
  }),
289
290
  ),
290
291
  write: async (plan, review) => {
@@ -295,7 +296,9 @@ export async function buildRealLoopDeps(
295
296
  : `## Plan\n${plan}`;
296
297
  return lastMessage(
297
298
  await coder.invoke({
298
- messages: [{ role: "user", content: _directive + withScoreboard(userMsg) }],
299
+ messages: [
300
+ { role: "user", content: _directive + withScoreboard(userMsg) },
301
+ ],
299
302
  }),
300
303
  );
301
304
  },
@@ -304,7 +307,9 @@ export async function buildRealLoopDeps(
304
307
  messages: [
305
308
  {
306
309
  role: "user",
307
- content: _directive + withScoreboard(`## Plan\n${plan}\n\n## Code to review\n${code}`),
310
+ content:
311
+ _directive +
312
+ withScoreboard(`## Plan\n${plan}\n\n## Code to review\n${code}`),
308
313
  },
309
314
  ],
310
315
  });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * StatusLineManager tests — T16
3
+ *
4
+ * Tests the pi-lens detection, quota formatting, and status file writing
5
+ * when pi-lens is not available.
6
+ */
7
+
8
+ import { describe, it, beforeEach, afterEach } from "node:test";
9
+ import assert from "node:assert";
10
+ import { existsSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "fs";
11
+ import { join } from "path";
12
+ import { StatusLineManager, isPiLensAvailable, formatQuotaLine } from "./status-line.js";
13
+
14
+ describe("isPiLensAvailable", () => {
15
+ it("returns false when no PI_LENS env vars are set", () => {
16
+ assert.ok(!isPiLensAvailable());
17
+ });
18
+ });
19
+
20
+ describe("formatQuotaLine", () => {
21
+ it("returns 'quota unknown' when no data", () => {
22
+ assert.ok(formatQuotaLine({}).startsWith("MiniMax: quota unknown"));
23
+ });
24
+
25
+ it("shows 5h usage when available", () => {
26
+ const line = formatQuotaLine({ fiveHourPercent: 89 });
27
+ assert.ok(line.includes("5h: 89% left"), line);
28
+ assert.ok(line.includes("MiniMax:"), line);
29
+ });
30
+
31
+ it("shows both 5h and weekly when both available", () => {
32
+ const line = formatQuotaLine({ fiveHourPercent: 89, weeklyPercent: 75 });
33
+ assert.ok(line.includes("5h: 89% left"), line);
34
+ assert.ok(line.includes("week: 75% left"), line);
35
+ });
36
+
37
+ it("shows 0% left when at 100%", () => {
38
+ const line = formatQuotaLine({ fiveHourPercent: 100 });
39
+ assert.ok(line.includes("0% left"), line);
40
+ });
41
+
42
+ it("shows ? for invalid negative percentage", () => {
43
+ // fmtPercent returns ? for negative values
44
+ const line = formatQuotaLine({ fiveHourPercent: -1 });
45
+ assert.ok(line.includes("5h: ?"), line);
46
+ });
47
+
48
+ it("shows reset countdown when resetAt is provided", () => {
49
+ const future = new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(); // 2h from now
50
+ const line = formatQuotaLine({ fiveHourPercent: 89, fiveHourResetAt: future });
51
+ assert.ok(line.includes("resets in"), line);
52
+ });
53
+ });
54
+
55
+ describe("StatusLineManager", () => {
56
+ const tmpDir = join(process.env.TMPDIR ?? "/tmp", `harness-status-test-${Date.now()}`);
57
+ let manager: StatusLineManager;
58
+
59
+ beforeEach(() => {
60
+ mkdirSync(tmpDir, { recursive: true });
61
+ manager = new StatusLineManager(tmpDir);
62
+ });
63
+
64
+ afterEach(() => {
65
+ manager.stop();
66
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
67
+ });
68
+
69
+ it("getLine returns combined loop + quota status", () => {
70
+ manager.updateLoopStatus("🧭 planning");
71
+ const line = manager.getLine();
72
+ assert.ok(line.includes("planning"), line);
73
+ });
74
+
75
+ it("getLine returns quota-only when no loop status", () => {
76
+ const line = manager.getLine();
77
+ assert.ok(line.includes("MiniMax"), line);
78
+ });
79
+
80
+ it("writes .harness-status file when pi-lens is not available", () => {
81
+ manager.updateLoopStatus("✍️ coding");
82
+ const statusFile = join(tmpDir, ".harness-status");
83
+ assert.ok(existsSync(statusFile), ".harness-status should exist");
84
+ const content = readFileSync(statusFile, "utf8");
85
+ assert.ok(content.includes("coding"), content);
86
+ });
87
+
88
+ it("start() and stop() do not throw", () => {
89
+ assert.doesNotThrow(() => manager.start());
90
+ assert.doesNotThrow(() => manager.stop());
91
+ });
92
+ });