@zhijiewang/openharness 2.1.0 → 2.3.1

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 (231) hide show
  1. package/README.md +4 -4
  2. package/dist/DeferredTool.js +3 -1
  3. package/dist/Tool.d.ts +1 -1
  4. package/dist/agents/roles.js +58 -62
  5. package/dist/commands/cybergotchi.d.ts +1 -1
  6. package/dist/commands/cybergotchi.js +30 -30
  7. package/dist/commands/index.js +288 -132
  8. package/dist/components/App.d.ts +1 -1
  9. package/dist/components/App.js +6 -6
  10. package/dist/components/CompanionFooter.d.ts +1 -1
  11. package/dist/components/CompanionFooter.js +6 -8
  12. package/dist/components/CybergotchiBubble.js +5 -5
  13. package/dist/components/CybergotchiPanel.d.ts +1 -1
  14. package/dist/components/CybergotchiPanel.js +7 -7
  15. package/dist/components/CybergotchiPanelConnected.js +2 -2
  16. package/dist/components/CybergotchiSetup.js +26 -24
  17. package/dist/components/CybergotchiSprite.d.ts +1 -1
  18. package/dist/components/CybergotchiSprite.js +8 -12
  19. package/dist/components/DiffView.d.ts +1 -1
  20. package/dist/components/DiffView.js +10 -10
  21. package/dist/components/ErrorBoundary.d.ts +1 -1
  22. package/dist/components/ErrorBoundary.js +1 -1
  23. package/dist/components/InitWizard.js +65 -33
  24. package/dist/components/Markdown.js +2 -4
  25. package/dist/components/Messages.js +4 -4
  26. package/dist/components/PermissionPrompt.d.ts +1 -1
  27. package/dist/components/PermissionPrompt.js +15 -17
  28. package/dist/components/REPL.d.ts +1 -1
  29. package/dist/components/REPL.js +74 -49
  30. package/dist/components/Spinner.js +2 -2
  31. package/dist/components/TextInput.js +35 -29
  32. package/dist/components/ToolCallDisplay.js +3 -5
  33. package/dist/cybergotchi/bones.d.ts +1 -1
  34. package/dist/cybergotchi/bones.js +8 -8
  35. package/dist/cybergotchi/config.d.ts +2 -2
  36. package/dist/cybergotchi/config.js +13 -13
  37. package/dist/cybergotchi/events.d.ts +5 -5
  38. package/dist/cybergotchi/events.js +7 -7
  39. package/dist/cybergotchi/needs.d.ts +2 -2
  40. package/dist/cybergotchi/needs.js +7 -9
  41. package/dist/cybergotchi/personality.d.ts +2 -2
  42. package/dist/cybergotchi/personality.js +2 -2
  43. package/dist/cybergotchi/species.d.ts +1 -1
  44. package/dist/cybergotchi/species.js +145 -217
  45. package/dist/cybergotchi/speech.d.ts +2 -2
  46. package/dist/cybergotchi/speech.js +43 -43
  47. package/dist/cybergotchi/types.d.ts +4 -4
  48. package/dist/cybergotchi/types.js +26 -26
  49. package/dist/cybergotchi/useCybergotchi.d.ts +1 -1
  50. package/dist/cybergotchi/useCybergotchi.js +29 -25
  51. package/dist/git/index.js +11 -9
  52. package/dist/harness/checkpoints.js +29 -21
  53. package/dist/harness/config.d.ts +3 -3
  54. package/dist/harness/config.js +15 -9
  55. package/dist/harness/context-warning.d.ts +1 -1
  56. package/dist/harness/context-warning.js +1 -1
  57. package/dist/harness/cost.js +1 -1
  58. package/dist/harness/credentials.js +13 -13
  59. package/dist/harness/hooks.js +7 -5
  60. package/dist/harness/keybindings.js +20 -18
  61. package/dist/harness/marketplace.d.ts +3 -3
  62. package/dist/harness/marketplace.js +55 -42
  63. package/dist/harness/memory.d.ts +23 -5
  64. package/dist/harness/memory.js +142 -41
  65. package/dist/harness/onboarding.js +30 -10
  66. package/dist/harness/plugins.d.ts +9 -1
  67. package/dist/harness/plugins.js +54 -30
  68. package/dist/harness/rules.js +12 -7
  69. package/dist/harness/sandbox.js +15 -15
  70. package/dist/harness/session-db.d.ts +55 -0
  71. package/dist/harness/session-db.js +165 -0
  72. package/dist/harness/session.d.ts +1 -1
  73. package/dist/harness/session.js +34 -15
  74. package/dist/harness/store.d.ts +3 -3
  75. package/dist/harness/store.js +6 -4
  76. package/dist/harness/submit-handler.d.ts +4 -4
  77. package/dist/harness/submit-handler.js +25 -23
  78. package/dist/harness/telemetry.d.ts +1 -1
  79. package/dist/harness/telemetry.js +23 -19
  80. package/dist/harness/traces.d.ts +2 -2
  81. package/dist/harness/traces.js +39 -33
  82. package/dist/harness/verification.d.ts +1 -1
  83. package/dist/harness/verification.js +50 -44
  84. package/dist/lsp/client.js +44 -40
  85. package/dist/main.js +114 -59
  86. package/dist/mcp/DeferredMcpTool.d.ts +4 -4
  87. package/dist/mcp/DeferredMcpTool.js +9 -5
  88. package/dist/mcp/McpTool.d.ts +4 -4
  89. package/dist/mcp/McpTool.js +8 -4
  90. package/dist/mcp/client.d.ts +2 -2
  91. package/dist/mcp/client.js +21 -21
  92. package/dist/mcp/loader.d.ts +1 -1
  93. package/dist/mcp/loader.js +17 -12
  94. package/dist/mcp/registry.d.ts +3 -3
  95. package/dist/mcp/registry.js +97 -97
  96. package/dist/mcp/schema.d.ts +1 -1
  97. package/dist/mcp/schema.js +16 -16
  98. package/dist/mcp/server.d.ts +1 -1
  99. package/dist/mcp/server.js +21 -21
  100. package/dist/mcp/types.d.ts +3 -3
  101. package/dist/providers/anthropic.d.ts +2 -2
  102. package/dist/providers/anthropic.js +10 -9
  103. package/dist/providers/base.d.ts +1 -1
  104. package/dist/providers/index.js +10 -3
  105. package/dist/providers/llamacpp.d.ts +2 -2
  106. package/dist/providers/llamacpp.js +1 -3
  107. package/dist/providers/ollama.d.ts +2 -2
  108. package/dist/providers/ollama.js +3 -4
  109. package/dist/providers/openai.d.ts +2 -2
  110. package/dist/providers/openai.js +3 -5
  111. package/dist/providers/openrouter.d.ts +2 -2
  112. package/dist/providers/router.d.ts +1 -1
  113. package/dist/providers/router.js +7 -7
  114. package/dist/query/compress.d.ts +2 -2
  115. package/dist/query/compress.js +22 -21
  116. package/dist/query/context-manager.d.ts +1 -1
  117. package/dist/query/context-manager.js +5 -5
  118. package/dist/query/errors.js +1 -1
  119. package/dist/query/index.d.ts +1 -1
  120. package/dist/query/index.js +42 -24
  121. package/dist/query/tools.js +15 -12
  122. package/dist/query/types.d.ts +3 -1
  123. package/dist/query.d.ts +1 -1
  124. package/dist/query.js +1 -1
  125. package/dist/remote/auth.d.ts +2 -2
  126. package/dist/remote/auth.js +8 -8
  127. package/dist/remote/server.d.ts +3 -3
  128. package/dist/remote/server.js +60 -60
  129. package/dist/renderer/cells.js +9 -9
  130. package/dist/renderer/colors.js +24 -6
  131. package/dist/renderer/diff.d.ts +2 -2
  132. package/dist/renderer/diff.js +27 -19
  133. package/dist/renderer/differ.d.ts +1 -1
  134. package/dist/renderer/differ.js +9 -9
  135. package/dist/renderer/image.js +19 -19
  136. package/dist/renderer/index.d.ts +6 -6
  137. package/dist/renderer/index.js +163 -93
  138. package/dist/renderer/input.js +66 -48
  139. package/dist/renderer/layout.d.ts +6 -6
  140. package/dist/renderer/layout.js +163 -124
  141. package/dist/renderer/markdown.d.ts +2 -2
  142. package/dist/renderer/markdown.js +173 -54
  143. package/dist/renderer/session-browser.d.ts +2 -2
  144. package/dist/renderer/session-browser.js +19 -21
  145. package/dist/repl.d.ts +5 -5
  146. package/dist/repl.js +311 -198
  147. package/dist/sdk/index.d.ts +5 -5
  148. package/dist/sdk/index.js +32 -26
  149. package/dist/services/AgentDispatcher.d.ts +3 -3
  150. package/dist/services/AgentDispatcher.js +33 -29
  151. package/dist/services/CronExecutor.d.ts +4 -4
  152. package/dist/services/CronExecutor.js +12 -8
  153. package/dist/services/EvaluatorLoop.d.ts +3 -3
  154. package/dist/services/EvaluatorLoop.js +29 -21
  155. package/dist/services/MetaHarness.d.ts +1 -1
  156. package/dist/services/MetaHarness.js +34 -32
  157. package/dist/services/PipelineExecutor.d.ts +1 -1
  158. package/dist/services/PipelineExecutor.js +23 -25
  159. package/dist/services/SkillExtractor.d.ts +43 -0
  160. package/dist/services/SkillExtractor.js +163 -0
  161. package/dist/services/StreamingToolExecutor.d.ts +2 -2
  162. package/dist/services/StreamingToolExecutor.js +11 -7
  163. package/dist/services/a2a.d.ts +8 -8
  164. package/dist/services/a2a.js +44 -34
  165. package/dist/services/agent-messaging.d.ts +33 -15
  166. package/dist/services/agent-messaging.js +65 -13
  167. package/dist/services/cron.js +16 -16
  168. package/dist/tools/AgentTool/index.d.ts +5 -2
  169. package/dist/tools/AgentTool/index.js +25 -39
  170. package/dist/tools/AskUserTool/index.js +1 -1
  171. package/dist/tools/BashTool/index.d.ts +2 -2
  172. package/dist/tools/BashTool/index.js +18 -10
  173. package/dist/tools/CronTool/index.js +30 -12
  174. package/dist/tools/DiagnosticsTool/index.js +28 -22
  175. package/dist/tools/EnterPlanModeTool/index.js +93 -14
  176. package/dist/tools/EnterWorktreeTool/index.js +7 -3
  177. package/dist/tools/ExitPlanModeTool/index.d.ts +22 -1
  178. package/dist/tools/ExitPlanModeTool/index.js +20 -5
  179. package/dist/tools/ExitWorktreeTool/index.js +11 -4
  180. package/dist/tools/FileEditTool/index.js +3 -5
  181. package/dist/tools/FileReadTool/index.js +16 -10
  182. package/dist/tools/FileWriteTool/index.js +2 -2
  183. package/dist/tools/GlobTool/index.js +5 -9
  184. package/dist/tools/GrepTool/index.d.ts +2 -2
  185. package/dist/tools/GrepTool/index.js +14 -9
  186. package/dist/tools/ImageReadTool/index.js +2 -2
  187. package/dist/tools/KillProcessTool/index.js +11 -7
  188. package/dist/tools/LSTool/index.js +3 -3
  189. package/dist/tools/MemoryTool/index.d.ts +5 -5
  190. package/dist/tools/MemoryTool/index.js +28 -14
  191. package/dist/tools/MonitorTool/index.js +24 -19
  192. package/dist/tools/MultiEditTool/index.js +9 -5
  193. package/dist/tools/NotebookEditTool/index.js +3 -3
  194. package/dist/tools/ParallelAgentTool/index.d.ts +4 -4
  195. package/dist/tools/ParallelAgentTool/index.js +12 -6
  196. package/dist/tools/PipelineTool/index.js +3 -3
  197. package/dist/tools/PowerShellTool/index.js +10 -6
  198. package/dist/tools/RemoteTriggerTool/index.js +8 -4
  199. package/dist/tools/ScheduleWakeupTool/index.d.ts +42 -0
  200. package/dist/tools/ScheduleWakeupTool/index.js +115 -0
  201. package/dist/tools/SendMessageTool/index.js +25 -7
  202. package/dist/tools/SessionSearchTool/index.d.ts +15 -0
  203. package/dist/tools/SessionSearchTool/index.js +36 -0
  204. package/dist/tools/SkillTool/index.d.ts +3 -0
  205. package/dist/tools/SkillTool/index.js +39 -9
  206. package/dist/tools/TaskCreateTool/index.d.ts +2 -2
  207. package/dist/tools/TaskCreateTool/index.js +2 -2
  208. package/dist/tools/TaskGetTool/index.js +2 -2
  209. package/dist/tools/TaskListTool/index.js +3 -5
  210. package/dist/tools/TaskOutputTool/index.js +2 -2
  211. package/dist/tools/TaskStopTool/index.js +3 -3
  212. package/dist/tools/TaskUpdateTool/index.d.ts +4 -4
  213. package/dist/tools/TaskUpdateTool/index.js +2 -2
  214. package/dist/tools/ToolSearchTool/index.js +9 -6
  215. package/dist/tools/WebFetchTool/index.js +1 -1
  216. package/dist/tools/WebSearchTool/index.js +2 -6
  217. package/dist/tools.js +31 -30
  218. package/dist/types/permissions.js +15 -9
  219. package/dist/utils/bash-safety.d.ts +1 -1
  220. package/dist/utils/bash-safety.js +64 -54
  221. package/dist/utils/diff-algorithm.d.ts +3 -3
  222. package/dist/utils/diff-algorithm.js +7 -7
  223. package/dist/utils/fs.js +3 -3
  224. package/dist/utils/safe-env.js +1 -1
  225. package/dist/utils/theme-data.d.ts +1 -1
  226. package/dist/utils/theme-data.js +1 -1
  227. package/dist/utils/theme.d.ts +1 -1
  228. package/dist/utils/theme.js +1 -1
  229. package/dist/utils/tool-summary.d.ts +1 -1
  230. package/dist/utils/tool-summary.js +27 -9
  231. package/package.json +10 -3
@@ -4,20 +4,18 @@
4
4
  * Commands are processed in the REPL before being sent to the LLM.
5
5
  * If input starts with /, it's treated as a command.
6
6
  */
7
- import { writeFileSync, mkdirSync, readFileSync, existsSync, readdirSync } from "node:fs";
8
- import { dirname } from "node:path";
9
- import { isGitRepo, gitDiff, gitUndo, gitCommit, gitLog, gitBranch } from "../git/index.js";
10
- import { handleCybergotchiCommand } from "./cybergotchi.js";
11
- import { connectedMcpServers } from "../mcp/loader.js";
12
- import { listSessions, loadSession, createSession, saveSession } from "../harness/session.js";
13
- import { readOhConfig } from "../harness/config.js";
7
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
14
8
  import { homedir } from "node:os";
15
- import { join } from "node:path";
16
- import { compressMessages } from "../query/index.js";
9
+ import { dirname, join } from "node:path";
10
+ import { gitBranch, gitCommit, gitDiff, gitLog, gitUndo, isGitRepo, isInMergeOrRebase } from "../git/index.js";
11
+ import { readOhConfig } from "../harness/config.js";
12
+ import { estimateMessageTokens } from "../harness/context-warning.js";
17
13
  import { getContextWindow } from "../harness/cost.js";
18
14
  import { loadKeybindings } from "../harness/keybindings.js";
19
- import { isInMergeOrRebase } from "../git/index.js";
20
- import { estimateMessageTokens } from "../harness/context-warning.js";
15
+ import { createSession, listSessions, loadSession, saveSession } from "../harness/session.js";
16
+ import { connectedMcpServers } from "../mcp/loader.js";
17
+ import { compressMessages } from "../query/index.js";
18
+ import { handleCybergotchiCommand } from "./cybergotchi.js";
21
19
  const commands = new Map();
22
20
  function register(name, description, handler) {
23
21
  commands.set(name, { description, handler });
@@ -25,12 +23,25 @@ function register(name, description, handler) {
25
23
  // ── Register all commands ──
26
24
  register("help", "Show available commands", () => {
27
25
  const categories = {
28
- 'Session': ['clear', 'compact', 'export', 'history', 'browse', 'resume', 'fork', 'pin', 'unpin'],
29
- 'Git': ['diff', 'undo', 'rewind', 'commit', 'log'],
30
- 'Info': ['help', 'cost', 'status', 'config', 'files', 'model', 'memory', 'doctor', 'context', 'mcp', 'mcp-registry'],
31
- 'Settings': ['theme', 'vim', 'companion', 'fast', 'keys', 'effort', 'sandbox'],
32
- 'AI': ['plan', 'review', 'roles', 'agents', 'plugins', 'btw'],
33
- 'Pet': ['cybergotchi'],
26
+ Session: ["clear", "compact", "export", "history", "browse", "resume", "fork", "pin", "unpin"],
27
+ Git: ["diff", "undo", "rewind", "commit", "log"],
28
+ Info: [
29
+ "help",
30
+ "cost",
31
+ "status",
32
+ "config",
33
+ "files",
34
+ "model",
35
+ "memory",
36
+ "doctor",
37
+ "context",
38
+ "mcp",
39
+ "mcp-registry",
40
+ "init",
41
+ ],
42
+ Settings: ["theme", "vim", "companion", "fast", "keys", "effort", "sandbox", "permissions", "allowed-tools"],
43
+ AI: ["plan", "review", "roles", "agents", "plugins", "btw", "loop"],
44
+ Pet: ["cybergotchi"],
34
45
  };
35
46
  const lines = [];
36
47
  for (const [category, names] of Object.entries(categories)) {
@@ -40,13 +51,13 @@ register("help", "Show available commands", () => {
40
51
  if (cmd)
41
52
  lines.push(` /${name.padEnd(12)} ${cmd.description}`);
42
53
  }
43
- lines.push('');
54
+ lines.push("");
44
55
  }
45
56
  // Include any uncategorized commands
46
57
  const categorized = new Set(Object.values(categories).flat());
47
- const uncategorized = [...commands.keys()].filter(n => !categorized.has(n));
58
+ const uncategorized = [...commands.keys()].filter((n) => !categorized.has(n));
48
59
  if (uncategorized.length > 0) {
49
- lines.push('Other:');
60
+ lines.push("Other:");
50
61
  for (const name of uncategorized) {
51
62
  const cmd = commands.get(name);
52
63
  lines.push(` /${name.padEnd(12)} ${cmd.description}`);
@@ -79,7 +90,7 @@ register("status", "Show session status", (_args, ctx) => {
79
90
  }
80
91
  const mcp = connectedMcpServers();
81
92
  if (mcp.length > 0) {
82
- lines.push(`MCP servers: ${mcp.join(', ')}`);
93
+ lines.push(`MCP servers: ${mcp.join(", ")}`);
83
94
  }
84
95
  return { output: lines.join("\n"), handled: true };
85
96
  });
@@ -114,15 +125,15 @@ register("rewind", "Restore files from checkpoint (interactive picker or last)",
114
125
  const cp = checkpoints[i];
115
126
  const age = Math.round((Date.now() - cp.timestamp) / 60_000);
116
127
  lines.push(` ${i + 1}. [${age}m ago] ${cp.description}`);
117
- lines.push(` Files: ${cp.files.join(', ')}`);
128
+ lines.push(` Files: ${cp.files.join(", ")}`);
118
129
  }
119
- lines.push('');
120
- lines.push('Usage: /rewind <number> to restore a specific checkpoint');
121
- lines.push(' /rewind last to restore the most recent');
122
- return { output: lines.join('\n'), handled: true };
130
+ lines.push("");
131
+ lines.push("Usage: /rewind <number> to restore a specific checkpoint");
132
+ lines.push(" /rewind last to restore the most recent");
133
+ return { output: lines.join("\n"), handled: true };
123
134
  }
124
135
  // /rewind last — restore most recent
125
- if (idx === 'last') {
136
+ if (idx === "last") {
126
137
  const cp = rewindLastCheckpoint();
127
138
  if (!cp)
128
139
  return { output: "No checkpoints.", handled: true };
@@ -133,7 +144,7 @@ register("rewind", "Restore files from checkpoint (interactive picker or last)",
133
144
  }
134
145
  // /rewind <n> — restore specific checkpoint
135
146
  const num = parseInt(idx, 10);
136
- if (isNaN(num) || num < 1 || num > checkpoints.length) {
147
+ if (Number.isNaN(num) || num < 1 || num > checkpoints.length) {
137
148
  return { output: `Invalid checkpoint number. Use 1-${checkpoints.length}.`, handled: true };
138
149
  }
139
150
  // Rewind to specific checkpoint (restore all from that point)
@@ -175,13 +186,15 @@ register("history", "List recent sessions or search across them", (args) => {
175
186
  for (const s of sessions) {
176
187
  try {
177
188
  const full = loadSession(s.id, sessionDir);
178
- const hit = full.messages.find(m => typeof m.content === "string" && m.content.toLowerCase().includes(term));
189
+ const hit = full.messages.find((m) => typeof m.content === "string" && m.content.toLowerCase().includes(term));
179
190
  if (hit) {
180
191
  const date = new Date(s.updatedAt).toLocaleDateString();
181
192
  matches.push(` ${s.id} ${date} ${s.model || "?"}`);
182
193
  }
183
194
  }
184
- catch { /* skip */ }
195
+ catch {
196
+ /* skip */
197
+ }
185
198
  }
186
199
  if (matches.length === 0)
187
200
  return { output: `No sessions matching "${term}".`, handled: true };
@@ -191,7 +204,7 @@ register("history", "List recent sessions or search across them", (args) => {
191
204
  const sessions = listSessions(sessionDir).slice(0, n);
192
205
  if (sessions.length === 0)
193
206
  return { output: "No saved sessions.", handled: true };
194
- const lines = sessions.map(s => {
207
+ const lines = sessions.map((s) => {
195
208
  const date = new Date(s.updatedAt).toLocaleDateString();
196
209
  const cost = s.cost > 0 ? ` $${s.cost.toFixed(4)}` : "";
197
210
  return ` ${s.id} ${date} ${String(s.messages).padStart(3)} msgs ${(s.model || "?").slice(0, 24)}${cost}`;
@@ -200,7 +213,7 @@ register("history", "List recent sessions or search across them", (args) => {
200
213
  });
201
214
  register("theme", "Switch theme (dark/light)", (args) => {
202
215
  const theme = args.trim().toLowerCase();
203
- if (theme !== 'dark' && theme !== 'light') {
216
+ if (theme !== "dark" && theme !== "light") {
204
217
  return { output: "Usage: /theme dark or /theme light", handled: true };
205
218
  }
206
219
  return { output: `__SWITCH_THEME__:${theme}`, handled: true };
@@ -244,7 +257,7 @@ register("files", "List files in context", (_args, ctx) => {
244
257
  }
245
258
  if (files.size === 0)
246
259
  return { output: "No files in context yet.", handled: true };
247
- return { output: `Files in context:\n${[...files].map(f => ` ${f}`).join("\n")}`, handled: true };
260
+ return { output: `Files in context:\n${[...files].map((f) => ` ${f}`).join("\n")}`, handled: true };
248
261
  });
249
262
  register("model", "Switch model (e.g., /model llama3.2 or /model ollama/llama3.2)", (args, ctx) => {
250
263
  const model = args.trim();
@@ -275,7 +288,7 @@ register("compact", "Compress conversation history (optional: focus keyword or m
275
288
  const targetTokens = Math.floor(getContextWindow(ctx.model) * 0.6);
276
289
  if (focus && /^\d+$/.test(focus)) {
277
290
  // Numeric: compact messages 1-N, keep N+1 onwards
278
- const cutoff = parseInt(focus);
291
+ const cutoff = parseInt(focus, 10);
279
292
  if (cutoff < 1 || cutoff >= before) {
280
293
  return { output: `Invalid: use 1-${before - 1}`, handled: true };
281
294
  }
@@ -289,8 +302,8 @@ register("compact", "Compress conversation history (optional: focus keyword or m
289
302
  if (focus) {
290
303
  // Keyword focus: compress but preserve messages containing the keyword
291
304
  const focusLower = focus.toLowerCase();
292
- const preserved = ctx.messages.filter(m => m.content.toLowerCase().includes(focusLower) || m.meta?.pinned);
293
- const others = ctx.messages.filter(m => !m.content.toLowerCase().includes(focusLower) && !m.meta?.pinned);
305
+ const preserved = ctx.messages.filter((m) => m.content.toLowerCase().includes(focusLower) || m.meta?.pinned);
306
+ const others = ctx.messages.filter((m) => !m.content.toLowerCase().includes(focusLower) && !m.meta?.pinned);
294
307
  const compactedOthers = compressMessages(others, targetTokens);
295
308
  const merged = [...compactedOthers, ...preserved].sort((a, b) => a.timestamp - b.timestamp);
296
309
  return {
@@ -310,8 +323,8 @@ register("compact", "Compress conversation history (optional: focus keyword or m
310
323
  });
311
324
  register("export", "Export conversation to file", (_args, ctx) => {
312
325
  const lines = ctx.messages
313
- .filter(m => m.role === "user" || m.role === "assistant")
314
- .map(m => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`)
326
+ .filter((m) => m.role === "user" || m.role === "assistant")
327
+ .map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`)
315
328
  .join("\n\n");
316
329
  const filename = `.oh/export-${ctx.sessionId}.md`;
317
330
  try {
@@ -355,7 +368,7 @@ register("memory", "View and search memories in .oh/memory/", (args) => {
355
368
  const term = args.trim().toLowerCase();
356
369
  let files;
357
370
  try {
358
- files = readdirSync(memDir).filter(f => f.endsWith(".md"));
371
+ files = readdirSync(memDir).filter((f) => f.endsWith(".md"));
359
372
  }
360
373
  catch {
361
374
  return { output: "Could not read .oh/memory/", handled: true };
@@ -369,11 +382,13 @@ register("memory", "View and search memories in .oh/memory/", (args) => {
369
382
  try {
370
383
  const content = readFileSync(join(memDir, file), "utf-8");
371
384
  if (content.toLowerCase().includes(term)) {
372
- const firstLine = content.split("\n").find(l => l.trim() && !l.startsWith("---")) ?? file;
385
+ const firstLine = content.split("\n").find((l) => l.trim() && !l.startsWith("---")) ?? file;
373
386
  matches.push(` ${file.padEnd(30)} ${firstLine.slice(0, 50)}`);
374
387
  }
375
388
  }
376
- catch { /* skip */ }
389
+ catch {
390
+ /* skip */
391
+ }
377
392
  }
378
393
  if (matches.length === 0)
379
394
  return { output: `No memories matching "${term}".`, handled: true };
@@ -397,11 +412,11 @@ register("memory", "View and search memories in .oh/memory/", (args) => {
397
412
  });
398
413
  register("companion", "Toggle companion visibility (off/on)", (args) => {
399
414
  const arg = args.trim().toLowerCase();
400
- if (arg === 'off')
401
- return { output: '__COMPANION_OFF__', handled: true };
402
- if (arg === 'on')
403
- return { output: '__COMPANION_ON__', handled: true };
404
- return { output: 'Usage: /companion off or /companion on', handled: true };
415
+ if (arg === "off")
416
+ return { output: "__COMPANION_OFF__", handled: true };
417
+ if (arg === "on")
418
+ return { output: "__COMPANION_ON__", handled: true };
419
+ return { output: "Usage: /companion off or /companion on", handled: true };
405
420
  });
406
421
  register("cybergotchi", "Manage your cybergotchi — feed · pet · rest · status · rename · reset", (args) => {
407
422
  return handleCybergotchiCommand(args);
@@ -412,32 +427,35 @@ register("roles", "List available agent specialization roles", () => {
412
427
  const lines = ["Available agent roles:\n"];
413
428
  for (const role of roles) {
414
429
  lines.push(` ${role.id.padEnd(18)} ${role.name}`);
415
- lines.push(` ${''.padEnd(18)} ${role.description}`);
430
+ lines.push(` ${"".padEnd(18)} ${role.description}`);
416
431
  if (role.suggestedTools?.length) {
417
- lines.push(` ${''.padEnd(18)} Tools: ${role.suggestedTools.join(', ')}`);
432
+ lines.push(` ${"".padEnd(18)} Tools: ${role.suggestedTools.join(", ")}`);
418
433
  }
419
- lines.push('');
434
+ lines.push("");
420
435
  }
421
436
  lines.push("Usage: Agent({ subagent_type: 'code-reviewer', prompt: '...' })");
422
437
  return { output: lines.join("\n"), handled: true };
423
438
  });
424
439
  register("agents", "Discover running openHarness agents on this machine", () => {
425
- const { discoverAgents } = require('../services/a2a.js');
440
+ const { discoverAgents } = require("../services/a2a.js");
426
441
  const agents = discoverAgents();
427
442
  if (agents.length === 0) {
428
- return { output: "No other openHarness agents running on this machine.\n\nOther oh sessions will appear here automatically via the A2A protocol.", handled: true };
443
+ return {
444
+ output: "No other openHarness agents running on this machine.\n\nOther oh sessions will appear here automatically via the A2A protocol.",
445
+ handled: true,
446
+ };
429
447
  }
430
448
  const lines = [`Running Agents (${agents.length}):\n`];
431
449
  for (const agent of agents) {
432
450
  const age = Math.round((Date.now() - agent.registeredAt) / 60_000);
433
451
  lines.push(` ${agent.name}`);
434
452
  lines.push(` ID: ${agent.id}`);
435
- lines.push(` Provider: ${agent.provider ?? 'unknown'} / ${agent.model ?? 'unknown'}`);
436
- lines.push(` Dir: ${agent.workingDir ?? 'unknown'}`);
437
- lines.push(` Endpoint: ${agent.endpoint.type}${agent.endpoint.port ? ':' + agent.endpoint.port : ''}`);
453
+ lines.push(` Provider: ${agent.provider ?? "unknown"} / ${agent.model ?? "unknown"}`);
454
+ lines.push(` Dir: ${agent.workingDir ?? "unknown"}`);
455
+ lines.push(` Endpoint: ${agent.endpoint.type}${agent.endpoint.port ? `:${agent.endpoint.port}` : ""}`);
438
456
  lines.push(` Uptime: ${age}m`);
439
- lines.push(` Caps: ${agent.capabilities.map((c) => c.name).join(', ')}`);
440
- lines.push('');
457
+ lines.push(` Caps: ${agent.capabilities.map((c) => c.name).join(", ")}`);
458
+ lines.push("");
441
459
  }
442
460
  lines.push("Send messages with: Agent({ prompt: 'ask the other agent...', allowed_tools: ['SendMessage'] })");
443
461
  return { output: lines.join("\n"), handled: true };
@@ -476,14 +494,17 @@ register("keys", "Show keyboard shortcuts", () => {
476
494
  return { output: shortcuts.join("\n"), handled: true };
477
495
  });
478
496
  register("sandbox", "Show sandbox status and restrictions", () => {
479
- const { sandboxStatus } = require('../harness/sandbox.js');
480
- return { output: sandboxStatus() + '\n\nConfigure in .oh/config.yaml under sandbox:', handled: true };
497
+ const { sandboxStatus } = require("../harness/sandbox.js");
498
+ return { output: `${sandboxStatus()}\n\nConfigure in .oh/config.yaml under sandbox:`, handled: true };
481
499
  });
482
500
  register("effort", "Set reasoning effort level (low/medium/high/max)", (args) => {
483
501
  const level = args.trim().toLowerCase();
484
- const valid = ['low', 'medium', 'high', 'max'];
502
+ const valid = ["low", "medium", "high", "max"];
485
503
  if (!valid.includes(level)) {
486
- return { output: `Usage: /effort <${valid.join('|')}>\n\nlow — fast, minimal reasoning\nmedium — balanced (default)\nhigh — thorough reasoning\nmax — maximum depth (Opus only)`, handled: true };
504
+ return {
505
+ output: `Usage: /effort <${valid.join("|")}>\n\nlow — fast, minimal reasoning\nmedium — balanced (default)\nhigh — thorough reasoning\nmax — maximum depth (Opus only)`,
506
+ handled: true,
507
+ };
487
508
  }
488
509
  return { output: `Effort level set to: ${level}`, handled: true };
489
510
  });
@@ -499,6 +520,38 @@ register("btw", "Ask a side question (ephemeral, no tools, not saved to history)
499
520
  prependToPrompt: `[Side question — answer briefly without using any tools. This is ephemeral and not part of the main conversation.]\n\n${args.trim()}`,
500
521
  };
501
522
  });
523
+ register("loop", "Run a prompt repeatedly with self-paced timing", (args) => {
524
+ const input = args.trim();
525
+ if (!input) {
526
+ return {
527
+ output: "Usage: /loop [interval] <prompt or /command>\n\nExamples:\n /loop check if the build passed\n /loop 5m /review\n\nOmit the interval to let the model self-pace via ScheduleWakeup.",
528
+ handled: true,
529
+ };
530
+ }
531
+ // Check for optional interval prefix like "5m", "30s", "2h"
532
+ const intervalMatch = input.match(/^(\d+)(s|m|h)\s+(.+)$/);
533
+ let intervalMs = null;
534
+ let prompt;
535
+ if (intervalMatch) {
536
+ const [, num, unit, rest] = intervalMatch;
537
+ const multipliers = { s: 1000, m: 60000, h: 3600000 };
538
+ intervalMs = parseInt(num, 10) * multipliers[unit];
539
+ prompt = rest;
540
+ }
541
+ else {
542
+ prompt = input;
543
+ }
544
+ const mode = intervalMs
545
+ ? `Fixed interval: ${intervalMatch[1]}${intervalMatch[2]}`
546
+ : "Dynamic (model self-paces via ScheduleWakeup)";
547
+ return {
548
+ output: `[loop] ${mode}\nPrompt: ${prompt}`,
549
+ handled: false,
550
+ prependToPrompt: intervalMs
551
+ ? `You are in LOOP MODE (fixed interval: ${intervalMs / 1000}s). Execute this task, then use ScheduleWakeup with delaySeconds=${intervalMs / 1000} to schedule the next iteration.\n\nTask: ${prompt}`
552
+ : `You are in LOOP MODE (dynamic pacing). Execute this task, then use ScheduleWakeup to schedule the next iteration at an appropriate interval. Choose your delay based on what you're waiting for. Omit the ScheduleWakeup call to end the loop.\n\nTask: ${prompt}`,
553
+ };
554
+ });
502
555
  register("plan", "Enter plan mode", (_args, _ctx) => {
503
556
  const task = _args.trim();
504
557
  if (!task) {
@@ -507,7 +560,7 @@ register("plan", "Enter plan mode", (_args, _ctx) => {
507
560
  return {
508
561
  output: `[plan mode] ${task}`,
509
562
  handled: false,
510
- prependToPrompt: `You are in PLAN MODE. Do NOT write any code yet. Instead, produce a detailed implementation plan as a numbered list covering: files to create/modify, key functions/types, data flow, and edge cases. Only after the plan is approved should you implement anything.\n\nTask: ${task}`,
563
+ prependToPrompt: `You are in PLAN MODE. Do NOT write any code yet.\n\n1. Call EnterPlanMode to create a plan file in .oh/plans/\n2. Write your detailed implementation plan to that file (files to create/modify, key functions/types, data flow, edge cases)\n3. When the plan is complete, call ExitPlanMode to signal readiness for review\n\nTask: ${task}`,
511
564
  };
512
565
  });
513
566
  register("review", "Review recent code changes", () => {
@@ -590,39 +643,45 @@ register("doctor", "Run diagnostic health checks", (_args, ctx) => {
590
643
  const ohDir = join(homedir(), ".oh");
591
644
  if (existsSync(ohDir)) {
592
645
  const sessionsDir = join(ohDir, "sessions");
593
- const sessCount = existsSync(sessionsDir) ? readdirSync(sessionsDir).filter(f => f.endsWith('.json')).length : 0;
646
+ const sessCount = existsSync(sessionsDir)
647
+ ? readdirSync(sessionsDir).filter((f) => f.endsWith(".json")).length
648
+ : 0;
594
649
  lines.push(` Sessions: ${sessCount} saved`);
595
650
  if (sessCount > 80)
596
651
  issues.push(`${sessCount} saved sessions. Consider cleaning old ones.`);
597
652
  // Memory stats
598
653
  const memDir = join(ohDir, "memory");
599
- const memCount = existsSync(memDir) ? readdirSync(memDir).filter(f => f.endsWith('.md')).length : 0;
654
+ const memCount = existsSync(memDir) ? readdirSync(memDir).filter((f) => f.endsWith(".md")).length : 0;
600
655
  lines.push(` Memories: ${memCount} global`);
601
656
  // Cron stats
602
657
  const cronDir = join(ohDir, "crons");
603
- const cronCount = existsSync(cronDir) ? readdirSync(cronDir).filter(f => f.endsWith('.json')).length : 0;
658
+ const cronCount = existsSync(cronDir) ? readdirSync(cronDir).filter((f) => f.endsWith(".json")).length : 0;
604
659
  lines.push(` Cron tasks: ${cronCount}`);
605
660
  }
606
661
  }
607
- catch { /* ignore */ }
662
+ catch {
663
+ /* ignore */
664
+ }
608
665
  // Project-level stats
609
666
  try {
610
667
  const projMemDir = join(".oh", "memory");
611
- const projMemCount = existsSync(projMemDir) ? readdirSync(projMemDir).filter(f => f.endsWith('.md')).length : 0;
668
+ const projMemCount = existsSync(projMemDir) ? readdirSync(projMemDir).filter((f) => f.endsWith(".md")).length : 0;
612
669
  if (projMemCount > 0)
613
670
  lines.push(` Project mems: ${projMemCount}`);
614
671
  const skillsDir = join(".oh", "skills");
615
- const skillCount = existsSync(skillsDir) ? readdirSync(skillsDir).filter(f => f.endsWith('.md')).length : 0;
672
+ const skillCount = existsSync(skillsDir) ? readdirSync(skillsDir).filter((f) => f.endsWith(".md")).length : 0;
616
673
  if (skillCount > 0)
617
674
  lines.push(` Skills: ${skillCount}`);
618
675
  }
619
- catch { /* ignore */ }
676
+ catch {
677
+ /* ignore */
678
+ }
620
679
  // Global config
621
680
  const globalCfg = existsSync(join(homedir(), ".oh", "config.yaml"));
622
681
  lines.push(` Global config: ${globalCfg ? "~/.oh/config.yaml ✓" : "not set (optional)"}`);
623
682
  // Verification config
624
683
  try {
625
- const { getVerificationConfig } = require('../harness/verification.js');
684
+ const { getVerificationConfig } = require("../harness/verification.js");
626
685
  const vCfg = getVerificationConfig();
627
686
  if (vCfg?.enabled) {
628
687
  lines.push(` Verification: ✓ (${vCfg.rules.length} rules, mode: ${vCfg.mode})`);
@@ -631,13 +690,15 @@ register("doctor", "Run diagnostic health checks", (_args, ctx) => {
631
690
  lines.push(` Verification: off (no rules detected)`);
632
691
  }
633
692
  }
634
- catch { /* ignore */ }
693
+ catch {
694
+ /* ignore */
695
+ }
635
696
  // Tools
636
697
  lines.push("");
637
- lines.push(` Tools: ${ctx.messages.length > 0 ? 'ready' : 'loaded'}`);
698
+ lines.push(` Tools: ${ctx.messages.length > 0 ? "ready" : "loaded"}`);
638
699
  // Node.js version
639
700
  lines.push(` Node.js: ${process.version}`);
640
- const [major] = process.version.slice(1).split('.').map(Number);
701
+ const [major] = process.version.slice(1).split(".").map(Number);
641
702
  if (major && major < 18)
642
703
  issues.push(`Node.js ${process.version} is below minimum (18+). Upgrade Node.js.`);
643
704
  // Issues summary
@@ -661,16 +722,16 @@ register("context", "Show context window usage breakdown", (_args, ctx) => {
661
722
  for (const msg of ctx.messages) {
662
723
  const tokens = Math.ceil((msg.content?.length ?? 0) / 4);
663
724
  switch (msg.role) {
664
- case 'user':
725
+ case "user":
665
726
  userTokens += tokens;
666
727
  break;
667
- case 'assistant':
728
+ case "assistant":
668
729
  assistantTokens += tokens;
669
730
  break;
670
- case 'tool':
731
+ case "tool":
671
732
  toolTokens += tokens;
672
733
  break;
673
- case 'system':
734
+ case "system":
674
735
  systemTokens += tokens;
675
736
  break;
676
737
  }
@@ -681,30 +742,33 @@ register("context", "Show context window usage breakdown", (_args, ctx) => {
681
742
  // Visual bar (30 chars wide)
682
743
  const barWidth = 30;
683
744
  const filled = Math.round(usage * barWidth);
684
- const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
745
+ const bar = "\u2588".repeat(filled) + "\u2591".repeat(barWidth - filled);
685
746
  const pct = (n) => `${((n / ctxWindow) * 100).toFixed(1)}%`;
686
747
  const pad = (s, n) => s.padEnd(n);
687
748
  const lines = [
688
749
  `Context Window (${ctxWindow.toLocaleString()} tokens):`,
689
- '',
690
- ` ${pad('User messages:', 20)} ${userTokens.toLocaleString().padStart(8)} tokens (${pct(userTokens)})`,
691
- ` ${pad('Assistant:', 20)} ${assistantTokens.toLocaleString().padStart(8)} tokens (${pct(assistantTokens)})`,
692
- ` ${pad('Tool results:', 20)} ${toolTokens.toLocaleString().padStart(8)} tokens (${pct(toolTokens)})`,
693
- ` ${pad('System/info:', 20)} ${systemTokens.toLocaleString().padStart(8)} tokens (${pct(systemTokens)})`,
694
- '',
695
- ` ${pad('Total used:', 20)} ${totalTokens.toLocaleString().padStart(8)} tokens (${pct(totalTokens)})`,
696
- ` ${pad('Free:', 20)} ${freeTokens.toLocaleString().padStart(8)} tokens (${pct(freeTokens)})`,
697
- '',
750
+ "",
751
+ ` ${pad("User messages:", 20)} ${userTokens.toLocaleString().padStart(8)} tokens (${pct(userTokens)})`,
752
+ ` ${pad("Assistant:", 20)} ${assistantTokens.toLocaleString().padStart(8)} tokens (${pct(assistantTokens)})`,
753
+ ` ${pad("Tool results:", 20)} ${toolTokens.toLocaleString().padStart(8)} tokens (${pct(toolTokens)})`,
754
+ ` ${pad("System/info:", 20)} ${systemTokens.toLocaleString().padStart(8)} tokens (${pct(systemTokens)})`,
755
+ "",
756
+ ` ${pad("Total used:", 20)} ${totalTokens.toLocaleString().padStart(8)} tokens (${pct(totalTokens)})`,
757
+ ` ${pad("Free:", 20)} ${freeTokens.toLocaleString().padStart(8)} tokens (${pct(freeTokens)})`,
758
+ "",
698
759
  ` ${bar} ${Math.round(usage * 100)}%`,
699
- '',
760
+ "",
700
761
  ` Messages: ${ctx.messages.length} | Compress at: ${Math.round(ctxWindow * 0.8).toLocaleString()} (80%)`,
701
762
  ];
702
- return { output: lines.join('\n'), handled: true };
763
+ return { output: lines.join("\n"), handled: true };
703
764
  });
704
765
  register("mcp", "Show MCP server status", () => {
705
766
  const mcp = connectedMcpServers();
706
767
  if (mcp.length === 0) {
707
- return { output: "No MCP servers connected.\nConfigure in .oh/config.yaml under mcpServers.\nRun /mcp-registry to browse available servers.", handled: true };
768
+ return {
769
+ output: "No MCP servers connected.\nConfigure in .oh/config.yaml under mcpServers.\nRun /mcp-registry to browse available servers.",
770
+ handled: true,
771
+ };
708
772
  }
709
773
  const lines = [`MCP Servers (${mcp.length} connected):\n`];
710
774
  for (const name of mcp) {
@@ -714,11 +778,11 @@ register("mcp", "Show MCP server status", () => {
714
778
  return { output: lines.join("\n"), handled: true };
715
779
  });
716
780
  register("mcp-registry", "Browse and add MCP servers from the curated registry", (args) => {
717
- const { searchRegistry, formatRegistry, generateConfigBlock, MCP_REGISTRY } = require('../mcp/registry.js');
781
+ const { searchRegistry, formatRegistry, generateConfigBlock, MCP_REGISTRY } = require("../mcp/registry.js");
718
782
  const query = args.trim();
719
783
  if (!query) {
720
784
  // Show full registry
721
- const output = `MCP Server Registry (${MCP_REGISTRY.length} servers)\n${''.repeat(50)}\n\n${formatRegistry()}\n\nUsage:\n /mcp-registry <name> Show install config for a server\n /mcp-registry <keyword> Search by name, description, or category`;
785
+ const output = `MCP Server Registry (${MCP_REGISTRY.length} servers)\n${"".repeat(50)}\n\n${formatRegistry()}\n\nUsage:\n /mcp-registry <name> Show install config for a server\n /mcp-registry <keyword> Search by name, description, or category`;
722
786
  return { output, handled: true };
723
787
  }
724
788
  // Search or show specific server
@@ -731,10 +795,10 @@ register("mcp-registry", "Browse and add MCP servers from the curated registry",
731
795
  const entry = results[0];
732
796
  const config = generateConfigBlock(entry);
733
797
  const envNote = entry.envVars?.length
734
- ? `\n\nRequired environment variables:\n${entry.envVars.map((v) => ` - ${v}`).join('\n')}`
735
- : '';
798
+ ? `\n\nRequired environment variables:\n${entry.envVars.map((v) => ` - ${v}`).join("\n")}`
799
+ : "";
736
800
  return {
737
- output: `${entry.name} — ${entry.description}\nPackage: ${entry.package}\nRisk: ${entry.riskLevel ?? 'medium'}${envNote}\n\nAdd to .oh/config.yaml under mcpServers:\n\n${config}`,
801
+ output: `${entry.name} — ${entry.description}\nPackage: ${entry.package}\nRisk: ${entry.riskLevel ?? "medium"}${envNote}\n\nAdd to .oh/config.yaml under mcpServers:\n\n${config}`,
738
802
  handled: true,
739
803
  };
740
804
  }
@@ -743,13 +807,13 @@ register("mcp-registry", "Browse and add MCP servers from the curated registry",
743
807
  });
744
808
  function setPinned(args, ctx, pinned) {
745
809
  const idx = parseInt(args.trim(), 10);
746
- if (isNaN(idx) || idx < 1 || idx > ctx.messages.length) {
747
- return { output: `Usage: /${pinned ? 'pin' : 'unpin'} <message-number> (1-${ctx.messages.length})`, handled: true };
810
+ if (Number.isNaN(idx) || idx < 1 || idx > ctx.messages.length) {
811
+ return { output: `Usage: /${pinned ? "pin" : "unpin"} <message-number> (1-${ctx.messages.length})`, handled: true };
748
812
  }
749
813
  // Immutable update — replace message with updated meta
750
- const updatedMessages = ctx.messages.map((m, i) => i === idx - 1 ? { ...m, meta: { ...m.meta, pinned } } : m);
814
+ const updatedMessages = ctx.messages.map((m, i) => (i === idx - 1 ? { ...m, meta: { ...m.meta, pinned } } : m));
751
815
  return {
752
- output: `Message #${idx} ${pinned ? 'pinned' : 'unpinned'}.`,
816
+ output: `Message #${idx} ${pinned ? "pinned" : "unpinned"}.`,
753
817
  handled: true,
754
818
  compactedMessages: updatedMessages,
755
819
  };
@@ -757,52 +821,64 @@ function setPinned(args, ctx, pinned) {
757
821
  register("pin", "Pin a message (survives /compact)", (args, ctx) => setPinned(args, ctx, true));
758
822
  register("unpin", "Unpin a message", (args, ctx) => setPinned(args, ctx, false));
759
823
  register("plugins", "Manage plugins: list, search, install, uninstall, marketplace", (args) => {
760
- const { discoverPlugins, discoverSkills } = require('../harness/plugins.js');
761
- const { searchMarketplace, installPlugin, uninstallPlugin, getInstalledPlugins, listMarketplaces, addMarketplace, removeMarketplace, formatMarketplaceSearch, formatInstalledPlugins, } = require('../harness/marketplace.js');
824
+ const { discoverPlugins, discoverSkills } = require("../harness/plugins.js");
825
+ const { searchMarketplace, installPlugin, uninstallPlugin, getInstalledPlugins, listMarketplaces, addMarketplace, removeMarketplace, formatMarketplaceSearch, formatInstalledPlugins, } = require("../harness/marketplace.js");
762
826
  const parts = args.trim().split(/\s+/);
763
- const subcommand = parts[0] ?? '';
764
- const rest = parts.slice(1).join(' ');
827
+ const subcommand = parts[0] ?? "";
828
+ const rest = parts.slice(1).join(" ");
765
829
  // /plugins marketplace add <source>
766
- if (subcommand === 'marketplace') {
830
+ if (subcommand === "marketplace") {
767
831
  const action = parts[1];
768
- const source = parts.slice(2).join(' ');
769
- if (action === 'add' && source) {
832
+ const source = parts.slice(2).join(" ");
833
+ if (action === "add" && source) {
770
834
  const mp = addMarketplace(source);
771
835
  if (mp)
772
836
  return { output: `Added marketplace "${mp.name}" (${mp.plugins.length} plugins)`, handled: true };
773
837
  return { output: `Failed to add marketplace from "${source}"`, handled: true };
774
838
  }
775
- if (action === 'remove' && source) {
776
- return { output: removeMarketplace(source) ? `Removed marketplace "${source}"` : `Marketplace "${source}" not found`, handled: true };
839
+ if (action === "remove" && source) {
840
+ return {
841
+ output: removeMarketplace(source) ? `Removed marketplace "${source}"` : `Marketplace "${source}" not found`,
842
+ handled: true,
843
+ };
777
844
  }
778
845
  // List marketplaces
779
846
  const mps = listMarketplaces();
780
847
  if (mps.length === 0) {
781
- return { output: 'No marketplaces configured.\n\nAdd one:\n /plugins marketplace add owner/repo\n /plugins marketplace add https://example.com/plugins', handled: true };
848
+ return {
849
+ output: "No marketplaces configured.\n\nAdd one:\n /plugins marketplace add owner/repo\n /plugins marketplace add https://example.com/plugins",
850
+ handled: true,
851
+ };
782
852
  }
783
853
  const lines = [`Marketplaces (${mps.length}):\n`];
784
854
  for (const mp of mps) {
785
855
  lines.push(` ${mp.name} — ${mp.plugins.length} plugins`);
786
856
  }
787
- return { output: lines.join('\n'), handled: true };
857
+ return { output: lines.join("\n"), handled: true };
788
858
  }
789
859
  // /plugins search <query>
790
- if (subcommand === 'search') {
791
- const query = rest || 'all';
792
- const results = searchMarketplace(query === 'all' ? '' : query);
860
+ if (subcommand === "search") {
861
+ const query = rest || "all";
862
+ const results = searchMarketplace(query === "all" ? "" : query);
793
863
  return { output: formatMarketplaceSearch(results), handled: true };
794
864
  }
795
865
  // /plugins install <name>
796
- if (subcommand === 'install' && rest) {
797
- const [name, marketplace] = rest.split('@');
866
+ if (subcommand === "install" && rest) {
867
+ const [name, marketplace] = rest.split("@");
798
868
  const result = installPlugin(name, marketplace);
799
869
  if (result) {
800
- return { output: `Installed ${result.name}@${result.version} from ${result.marketplace}\nCached at: ${result.cachePath}`, handled: true };
870
+ return {
871
+ output: `Installed ${result.name}@${result.version} from ${result.marketplace}\nCached at: ${result.cachePath}`,
872
+ handled: true,
873
+ };
801
874
  }
802
- return { output: `Failed to install "${rest}". Is it listed in a marketplace?\nRun /plugins search ${name} to check.`, handled: true };
875
+ return {
876
+ output: `Failed to install "${rest}". Is it listed in a marketplace?\nRun /plugins search ${name} to check.`,
877
+ handled: true,
878
+ };
803
879
  }
804
880
  // /plugins uninstall <name>
805
- if (subcommand === 'uninstall' && rest) {
881
+ if (subcommand === "uninstall" && rest) {
806
882
  return { output: uninstallPlugin(rest) ? `Uninstalled "${rest}"` : `Plugin "${rest}" not found`, handled: true };
807
883
  }
808
884
  // /plugins (no args) — show everything
@@ -812,33 +888,110 @@ register("plugins", "Manage plugins: list, search, install, uninstall, marketpla
812
888
  const lines = [];
813
889
  if (marketplacePlugins.length > 0) {
814
890
  lines.push(formatInstalledPlugins(marketplacePlugins));
815
- lines.push('');
891
+ lines.push("");
816
892
  }
817
893
  if (plugins.length > 0) {
818
894
  lines.push(`Local Plugins (${plugins.length}):`);
819
895
  for (const p of plugins) {
820
- lines.push(` ${p.name}@${p.version} — ${p.description || 'no description'}`);
896
+ lines.push(` ${p.name}@${p.version} — ${p.description || "no description"}`);
821
897
  }
822
- lines.push('');
898
+ lines.push("");
823
899
  }
824
900
  if (skills.length > 0) {
825
901
  lines.push(`Skills (${skills.length}):`);
826
902
  for (const s of skills) {
827
- lines.push(` ${s.source}:${s.name} — ${s.description || ''}`);
903
+ lines.push(` ${s.source}:${s.name} — ${s.description || ""}`);
828
904
  }
829
- lines.push('');
905
+ lines.push("");
830
906
  }
831
907
  if (lines.length === 0) {
832
- lines.push('No plugins or skills installed.');
908
+ lines.push("No plugins or skills installed.");
909
+ }
910
+ lines.push("");
911
+ lines.push("Commands:");
912
+ lines.push(" /plugins search <query> Search marketplaces");
913
+ lines.push(" /plugins install <name> Install from marketplace");
914
+ lines.push(" /plugins uninstall <name> Remove a plugin");
915
+ lines.push(" /plugins marketplace add <src> Add a marketplace");
916
+ lines.push(" /plugins marketplace List marketplaces");
917
+ return { output: lines.join("\n"), handled: true };
918
+ });
919
+ // ── Project Init ──
920
+ register("init", "Initialize project with .oh/ config", () => {
921
+ const ohDir = join(process.cwd(), ".oh");
922
+ if (existsSync(ohDir)) {
923
+ return { output: ".oh/ directory already exists. Project is already initialized.", handled: true };
924
+ }
925
+ mkdirSync(ohDir, { recursive: true });
926
+ const rulesPath = join(ohDir, "RULES.md");
927
+ if (!existsSync(rulesPath)) {
928
+ writeFileSync(rulesPath, `# Project Rules
929
+
930
+ <!-- Add project-specific instructions here. These are loaded into every session. -->
931
+ <!-- Examples: coding conventions, testing requirements, deployment guidelines. -->
932
+ `);
933
+ }
934
+ const configPath = join(ohDir, "config.yaml");
935
+ if (!existsSync(configPath)) {
936
+ writeFileSync(configPath, `# OpenHarness project config
937
+ # provider: ollama
938
+ # model: llama3
939
+ # permissionMode: ask
940
+ `);
941
+ }
942
+ return {
943
+ output: `Initialized .oh/ with:\n .oh/RULES.md — project rules\n .oh/config.yaml — project config\n\nEdit these files to customize your project.`,
944
+ handled: true,
945
+ };
946
+ });
947
+ // ── Permissions ──
948
+ register("permissions", "View or change permission mode", (args, ctx) => {
949
+ const mode = args.trim().toLowerCase();
950
+ if (!mode) {
951
+ return {
952
+ output: `Current permission mode: ${ctx.permissionMode}\n\nAvailable modes:\n ask Prompt for medium/high risk (default)\n trust Auto-approve everything\n deny Only low-risk read-only\n acceptEdits Auto-approve file edits\n plan Read-only mode\n auto Auto-approve, block dangerous bash\n bypassPermissions CI/CD only`,
953
+ handled: true,
954
+ };
955
+ }
956
+ const valid = ["ask", "trust", "deny", "acceptedits", "plan", "auto", "bypasspermissions"];
957
+ if (!valid.includes(mode)) {
958
+ return { output: `Unknown mode: ${mode}. Valid: ${valid.join(", ")}`, handled: true };
833
959
  }
834
- lines.push('');
835
- lines.push('Commands:');
836
- lines.push(' /plugins search <query> Search marketplaces');
837
- lines.push(' /plugins install <name> Install from marketplace');
838
- lines.push(' /plugins uninstall <name> Remove a plugin');
839
- lines.push(' /plugins marketplace add <src> Add a marketplace');
840
- lines.push(' /plugins marketplace List marketplaces');
841
- return { output: lines.join('\n'), handled: true };
960
+ return {
961
+ output: `Permission mode set to: ${mode}\n(Note: takes effect for new tool calls in this session)`,
962
+ handled: true,
963
+ };
964
+ });
965
+ register("allowed-tools", "View tool permission rules", () => {
966
+ const config = readOhConfig();
967
+ const rules = config?.toolPermissions;
968
+ if (!rules || rules.length === 0) {
969
+ return {
970
+ output: 'No custom tool permission rules configured.\n\nAdd rules to .oh/config.yaml:\n\ntoolPermissions:\n - tool: Bash\n action: ask\n pattern: "^rm .*"',
971
+ handled: true,
972
+ };
973
+ }
974
+ const lines = rules.map((r) => {
975
+ const parts = [` ${r.tool}: ${r.action}`];
976
+ if (r.pattern)
977
+ parts.push(`(pattern: ${r.pattern})`);
978
+ return parts.join(" ");
979
+ });
980
+ return { output: `Tool permission rules:\n${lines.join("\n")}`, handled: true };
981
+ });
982
+ register("rebuild-sessions", "Rebuild session search index", () => {
983
+ // Fire async rebuild, return immediately with status
984
+ import("../harness/session-db.js")
985
+ .then(({ openSessionDb, rebuildIndex, closeSessionDb }) => {
986
+ const db = openSessionDb();
987
+ const count = rebuildIndex(db);
988
+ closeSessionDb(db);
989
+ console.log(`Rebuilt session search index: ${count} sessions indexed.`);
990
+ })
991
+ .catch((err) => {
992
+ console.log(`Failed to rebuild index: ${err.message}`);
993
+ });
994
+ return { output: "Rebuilding session search index...", handled: true };
842
995
  });
843
996
  // ── Command Parser ──
844
997
  /**
@@ -853,7 +1006,10 @@ export function processSlashCommand(input, context) {
853
1006
  const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1);
854
1007
  // Resolve aliases
855
1008
  const aliases = {
856
- h: 'help', c: 'commit', m: 'model', s: 'status',
1009
+ h: "help",
1010
+ c: "commit",
1011
+ m: "model",
1012
+ s: "status",
857
1013
  };
858
1014
  const resolved = aliases[name] ?? name;
859
1015
  const cmd = commands.get(resolved);