pi-harness-runtime 1.1.43 → 1.1.45

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 (97) hide show
  1. package/harness/langchain/agents.test.ts +40 -0
  2. package/harness/langchain/agents.ts +49 -5
  3. package/harness/langchain/daemon.ts +126 -1
  4. package/harness/langchain/file-tools.ts +178 -0
  5. package/harness/langchain/graph.ts +22 -10
  6. package/harness/langchain/status-line.test.ts +92 -0
  7. package/harness/langchain/status-line.ts +255 -0
  8. package/package.json +1 -1
  9. package/packages/a2a-adapter/package.json +1 -1
  10. package/packages/architecture-generator/package.json +1 -1
  11. package/packages/auth/package.json +1 -1
  12. package/packages/autonomous-refactor/package.json +1 -1
  13. package/packages/autonomous-runtime/package.json +1 -1
  14. package/packages/cache-strategy/package.json +1 -1
  15. package/packages/capability-registry/package.json +1 -1
  16. package/packages/checkpoint/package.json +1 -1
  17. package/packages/cli-plugin-sdk/package.json +1 -1
  18. package/packages/clipboard/package.json +1 -1
  19. package/packages/clipboard-plugin/package.json +1 -1
  20. package/packages/code-generation/package.json +1 -1
  21. package/packages/code-review/package.json +1 -1
  22. package/packages/codex-adapter/package.json +1 -1
  23. package/packages/config-capture/package.json +1 -1
  24. package/packages/context-compiler/package.json +1 -1
  25. package/packages/context-discovery/package.json +1 -1
  26. package/packages/context-manager/package.json +1 -1
  27. package/packages/cookie-sanitizer/package.json +1 -1
  28. package/packages/cost-optimizer/package.json +1 -1
  29. package/packages/dependency-analyzer/package.json +1 -1
  30. package/packages/django-plugin/package.json +1 -1
  31. package/packages/doc-generator/package.json +1 -1
  32. package/packages/evaluation-engine/package.json +1 -1
  33. package/packages/evaluation-runner/package.json +1 -1
  34. package/packages/event-bus/package.json +1 -1
  35. package/packages/event-store/package.json +1 -1
  36. package/packages/experience-replay/package.json +1 -1
  37. package/packages/feedback-collector/package.json +1 -1
  38. package/packages/file-copy-helper/package.json +1 -1
  39. package/packages/framework-detector/package.json +1 -1
  40. package/packages/framework-plugin-sdk/package.json +1 -1
  41. package/packages/frappe-plugin/package.json +1 -1
  42. package/packages/generic-web-plugin/package.json +1 -1
  43. package/packages/health-monitor/package.json +1 -1
  44. package/packages/intent-analyzer/package.json +1 -1
  45. package/packages/knowledge-graph/package.json +1 -1
  46. package/packages/knowledge-retrieval/package.json +1 -1
  47. package/packages/laravel-plugin/package.json +1 -1
  48. package/packages/learning-engine/package.json +1 -1
  49. package/packages/mcp-adapter/package.json +1 -1
  50. package/packages/memory-engine/package.json +1 -1
  51. package/packages/milestone-manager/package.json +1 -1
  52. package/packages/model-registry/package.json +1 -1
  53. package/packages/nextjs-plugin/package.json +1 -1
  54. package/packages/notification/package.json +1 -1
  55. package/packages/observability/package.json +1 -1
  56. package/packages/okf-indexer/package.json +1 -1
  57. package/packages/performance-optimizer/package.json +1 -1
  58. package/packages/privilege-broker/package.json +1 -1
  59. package/packages/project-analyzer/package.json +1 -1
  60. package/packages/project-bootstrap/package.json +1 -1
  61. package/packages/projection-engine/package.json +1 -1
  62. package/packages/prompt-compiler/package.json +1 -1
  63. package/packages/prompt-versioning/package.json +1 -1
  64. package/packages/provider-adapter-sdk/package.json +1 -1
  65. package/packages/provider-router/package.json +1 -1
  66. package/packages/provider-selector/package.json +1 -1
  67. package/packages/providers/package.json +1 -1
  68. package/packages/quota-manager/package.json +1 -1
  69. package/packages/rate-limiter/package.json +1 -1
  70. package/packages/react-vite-plugin/package.json +1 -1
  71. package/packages/release-manager/package.json +1 -1
  72. package/packages/requirement-compiler/package.json +1 -1
  73. package/packages/runtime/package.json +1 -1
  74. package/packages/scheduler/package.json +1 -1
  75. package/packages/scheduler-adapter/package.json +1 -1
  76. package/packages/session/package.json +1 -1
  77. package/packages/session-api/package.json +1 -1
  78. package/packages/session-export/package.json +1 -1
  79. package/packages/shared-context/package.json +1 -1
  80. package/packages/skill-mcp-client/package.json +1 -1
  81. package/packages/skill-registry/package.json +1 -1
  82. package/packages/sprint-planner/package.json +1 -1
  83. package/packages/subscription-engine/package.json +1 -1
  84. package/packages/task-compiler/package.json +1 -1
  85. package/packages/tencentdb-memory/package.json +1 -1
  86. package/packages/tencentdb-sync/package.json +1 -1
  87. package/packages/test-data-generator/package.json +1 -1
  88. package/packages/test-generator/package.json +1 -1
  89. package/packages/todo-bd-sync/package.json +1 -1
  90. package/packages/token-estimation/package.json +1 -1
  91. package/packages/token-optimizer/package.json +1 -1
  92. package/packages/tui/package.json +1 -1
  93. package/packages/types/package.json +1 -1
  94. package/packages/workflow-events/package.json +1 -1
  95. package/packages/workspace-scanner/package.json +1 -1
  96. package/packages/worktree/package.json +1 -1
  97. package/packages/write-review/package.json +1 -1
@@ -0,0 +1,40 @@
1
+
2
+ import { describe, it } from "node:test";
3
+ import assert from "node:assert";
4
+ import { isAutonomyRequest } from "./agents.js";
5
+
6
+ describe("isAutonomyRequest", () => {
7
+ const shouldMatch = [
8
+ "You are free to write code until finish without my permission",
9
+ "you are free to write code until finish without permission",
10
+ "free to write code without permission",
11
+ "free to implement until done",
12
+ "free to build until complete",
13
+ "autonomous mode",
14
+ "autonomous",
15
+ "self-directed coding",
16
+ "no need for human approval",
17
+ "no need for confirmation",
18
+ "proceed without my approval",
19
+ "proceed without human",
20
+ ];
21
+
22
+ const shouldNotMatch = [
23
+ "please review the code",
24
+ "check the output",
25
+ "run tests and report",
26
+ "waiting for human input",
27
+ ];
28
+
29
+ for (const text of shouldMatch) {
30
+ it(`matches: "${text.slice(0, 50)}"`, () => {
31
+ assert.ok(isAutonomyRequest(text), `Expected to match: ${text}`);
32
+ });
33
+ }
34
+
35
+ for (const text of shouldNotMatch) {
36
+ it(`does NOT match: "${text}"`, () => {
37
+ assert.ok(!isAutonomyRequest(text), `Expected NOT to match: ${text}`);
38
+ });
39
+ }
40
+ });
@@ -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,
@@ -46,6 +47,40 @@ export const ReviewVerdictSchema = z.object({
46
47
 
47
48
  export type ReviewVerdict = z.infer<typeof ReviewVerdictSchema>;
48
49
 
50
+ // ─── Autonomy Signal Detection ────────────────────────────────────────────
51
+ //
52
+ // When these phrases appear in the user request, the agents activate full
53
+ // autonomous mode: no waiting, no "Next Steps", no confirmation prompts.
54
+ // The directive is injected into every system prompt when a signal is detected.
55
+ //
56
+ // Human says: "You are free to write code until finish without my permission"
57
+ // → Agents interpret this as: autonomy unlocked, write freely
58
+
59
+ const AUTONOMY_SIGNALS = [
60
+ /free to (write|code|implement|build|create)/i,
61
+ /until (finish|complete|done)/i,
62
+ /without (my )?permission/i,
63
+ /\bautonomous(ly)?( mode)?\b/i,
64
+ /self[- ]?directed/i,
65
+ /no (need (to|for)|waiting for) (human|confirmation|approval|permission)/i,
66
+ /proceed without (me|human|my |approval)/i,
67
+ ];
68
+
69
+ export function isAutonomyRequest(request: string): boolean {
70
+ return AUTONOMY_SIGNALS.some((re) => re.test(request));
71
+ }
72
+
73
+ /** Directive injected into every system prompt when autonomy signal is detected. */
74
+ export function autonomyDirective(): string {
75
+ return `\n\n[AUTONOMY MODE ACTIVE]
76
+ You are operating in full autonomous mode. The human has explicitly granted
77
+ permission to write code until finish without waiting for confirmation.
78
+ - Do NOT ask for permission to proceed
79
+ - Do NOT output "Next Steps" or waiting-for-human signals
80
+ - Do NOT wait for human review — the loop routes automatically
81
+ - Write the code and output the result; the loop handles the rest`;
82
+ }
83
+
49
84
  // ─── Prompts ────────────────────────────────────────────────────────────────
50
85
  //
51
86
  // PROMPTS ARE WRITTEN FOR DAEMON AUDIENCE, NOT HUMAN READERS.
@@ -57,7 +92,6 @@ export type ReviewVerdict = z.infer<typeof ReviewVerdictSchema>;
57
92
  // - If stuck, fail with a clear error, do not wait silently
58
93
  // - Output is consumed by the loop, not a human reading for guidance
59
94
 
60
-
61
95
  const PLANNER_PROMPT = `You are the Planning Agent (GPT) in an autonomous daemon loop.
62
96
 
63
97
  Given a feature request, produce a concise implementation plan: goals, non-goals,
@@ -70,8 +104,19 @@ The loop will route automatically.`;
70
104
  const CODER_PROMPT = `You are the Coding Agent (MiniMax) in an autonomous daemon loop.
71
105
 
72
106
  You receive a plan (and possibly review comments from a previous iteration) and
73
- produce complete, runnable code. Output fenced code blocks with file-path headers.
74
- 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
75
120
 
76
121
  IMPORTANT:
77
122
  - Do NOT output "Next Steps", "Ready for review", or any waiting-for-human signal
@@ -79,7 +124,6 @@ IMPORTANT:
79
124
  - If you cannot proceed, fail with a clear error message
80
125
  - Your output is consumed by the loop, not read by a human for guidance`;
81
126
 
82
-
83
127
  const REVIEWER_PROMPT = `You are the Code Review Agent (GPT) in an autonomous daemon loop.
84
128
 
85
129
  You review the coder's output against the plan. Be strict: request changes only
@@ -115,7 +159,7 @@ export function createPlannerAgent(opts: ModelOptions = {}) {
115
159
  export function createCoderAgent(opts: ModelOptions = {}) {
116
160
  return createAgent({
117
161
  model: createCoderModel(opts),
118
- tools: [], // roadmap: file read/write tools for real repo edits
162
+ tools: fileTools,
119
163
  systemPrompt: CODER_PROMPT,
120
164
  });
121
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];
@@ -20,6 +20,8 @@ import {
20
20
  StateGraph,
21
21
  } from "@langchain/langgraph";
22
22
  import {
23
+ autonomyDirective,
24
+ isAutonomyRequest,
23
25
  lastMessage,
24
26
  type ReviewVerdict,
25
27
  ReviewVerdictSchema,
@@ -226,6 +228,10 @@ export async function buildRealLoopDeps(
226
228
  blackboard.init();
227
229
  }
228
230
 
231
+ // Autonomy mode: computed inside plan(), stored for write()/review()
232
+ const _autonomous = false;
233
+ const _directive = "";
234
+
229
235
  /** Inject scoreboard markdown into a user message. */
230
236
  const withScoreboard = (msg: string) => {
231
237
  const scoreboard = blackboard.toMarkdown();
@@ -259,8 +265,9 @@ export async function buildRealLoopDeps(
259
265
  }
260
266
  if (state.review?.comments && state.review.comments.length > 0) {
261
267
  blackboard.setChangesRequested(
262
- state.review.comments.map((c) =>
263
- `[${c.severity ?? "minor"}] ${c.file ? `${c.file}: ` : ""}${c.comment}`,
268
+ state.review.comments.map(
269
+ (c) =>
270
+ `[${c.severity ?? "minor"}] ${c.file ? `${c.file}: ` : ""}${c.comment}`,
264
271
  ),
265
272
  );
266
273
  }
@@ -276,7 +283,9 @@ export async function buildRealLoopDeps(
276
283
  plan: async (request) =>
277
284
  lastMessage(
278
285
  await planner.invoke({
279
- messages: [{ role: "user", content: withScoreboard(request) }],
286
+ messages: [
287
+ { role: "user", content: _directive + withScoreboard(request) },
288
+ ],
280
289
  }),
281
290
  ),
282
291
  write: async (plan, review) => {
@@ -287,7 +296,9 @@ export async function buildRealLoopDeps(
287
296
  : `## Plan\n${plan}`;
288
297
  return lastMessage(
289
298
  await coder.invoke({
290
- messages: [{ role: "user", content: withScoreboard(userMsg) }],
299
+ messages: [
300
+ { role: "user", content: _directive + withScoreboard(userMsg) },
301
+ ],
291
302
  }),
292
303
  );
293
304
  },
@@ -296,9 +307,9 @@ export async function buildRealLoopDeps(
296
307
  messages: [
297
308
  {
298
309
  role: "user",
299
- content: withScoreboard(
300
- `## Plan\n${plan}\n\n## Code to review\n${code}`,
301
- ),
310
+ content:
311
+ _directive +
312
+ withScoreboard(`## Plan\n${plan}\n\n## Code to review\n${code}`),
302
313
  },
303
314
  ],
304
315
  });
@@ -367,8 +378,9 @@ export function buildDryRunDeps(
367
378
  if (verdict) blackboard.setVerdict(verdict, state.review?.summary);
368
379
  if (state.review?.comments && state.review.comments.length > 0) {
369
380
  blackboard.setChangesRequested(
370
- state.review.comments.map((c) =>
371
- `[${c.severity ?? "minor"}] ${c.file ? `${c.file}: ` : ""}${c.comment}`,
381
+ state.review.comments.map(
382
+ (c) =>
383
+ `[${c.severity ?? "minor"}] ${c.file ? `${c.file}: ` : ""}${c.comment}`,
372
384
  ),
373
385
  );
374
386
  }
@@ -378,7 +390,7 @@ export function buildDryRunDeps(
378
390
  }
379
391
  };
380
392
 
381
- return {
393
+ return {
382
394
  maxIterations: options.maxIterations ?? 3,
383
395
  onStep,
384
396
  plan: async (request) =>
@@ -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
+ });