@zhijiewang/openharness 2.0.0 → 2.3.0

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 (235) 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 +360 -122
  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 +12 -2
  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.d.ts +34 -0
  70. package/dist/harness/sandbox.js +104 -0
  71. package/dist/harness/session-db.d.ts +55 -0
  72. package/dist/harness/session-db.js +165 -0
  73. package/dist/harness/session.d.ts +1 -1
  74. package/dist/harness/session.js +34 -15
  75. package/dist/harness/store.d.ts +3 -3
  76. package/dist/harness/store.js +6 -4
  77. package/dist/harness/submit-handler.d.ts +4 -4
  78. package/dist/harness/submit-handler.js +57 -21
  79. package/dist/harness/telemetry.d.ts +1 -1
  80. package/dist/harness/telemetry.js +23 -19
  81. package/dist/harness/traces.d.ts +2 -2
  82. package/dist/harness/traces.js +44 -33
  83. package/dist/harness/verification.d.ts +1 -1
  84. package/dist/harness/verification.js +50 -44
  85. package/dist/lsp/client.js +44 -40
  86. package/dist/main.js +100 -59
  87. package/dist/mcp/DeferredMcpTool.d.ts +4 -4
  88. package/dist/mcp/DeferredMcpTool.js +9 -5
  89. package/dist/mcp/McpTool.d.ts +4 -4
  90. package/dist/mcp/McpTool.js +8 -4
  91. package/dist/mcp/client.d.ts +2 -2
  92. package/dist/mcp/client.js +21 -21
  93. package/dist/mcp/loader.d.ts +1 -1
  94. package/dist/mcp/loader.js +17 -12
  95. package/dist/mcp/registry.d.ts +3 -3
  96. package/dist/mcp/registry.js +97 -97
  97. package/dist/mcp/schema.d.ts +1 -1
  98. package/dist/mcp/schema.js +16 -16
  99. package/dist/mcp/server.d.ts +1 -1
  100. package/dist/mcp/server.js +21 -21
  101. package/dist/mcp/types.d.ts +3 -3
  102. package/dist/providers/anthropic.d.ts +2 -2
  103. package/dist/providers/anthropic.js +10 -9
  104. package/dist/providers/base.d.ts +1 -1
  105. package/dist/providers/index.js +10 -3
  106. package/dist/providers/llamacpp.d.ts +2 -2
  107. package/dist/providers/llamacpp.js +1 -3
  108. package/dist/providers/ollama.d.ts +2 -2
  109. package/dist/providers/ollama.js +3 -4
  110. package/dist/providers/openai.d.ts +2 -2
  111. package/dist/providers/openai.js +3 -5
  112. package/dist/providers/openrouter.d.ts +2 -2
  113. package/dist/providers/router.d.ts +1 -1
  114. package/dist/providers/router.js +7 -7
  115. package/dist/query/compress.d.ts +2 -2
  116. package/dist/query/compress.js +22 -21
  117. package/dist/query/context-manager.d.ts +2 -2
  118. package/dist/query/context-manager.js +8 -11
  119. package/dist/query/errors.js +1 -1
  120. package/dist/query/index.d.ts +1 -1
  121. package/dist/query/index.js +30 -22
  122. package/dist/query/tools.js +15 -12
  123. package/dist/query/types.d.ts +1 -1
  124. package/dist/query.d.ts +1 -1
  125. package/dist/query.js +1 -1
  126. package/dist/remote/auth.d.ts +2 -2
  127. package/dist/remote/auth.js +8 -8
  128. package/dist/remote/server.d.ts +3 -3
  129. package/dist/remote/server.js +60 -60
  130. package/dist/renderer/cells.js +9 -9
  131. package/dist/renderer/colors.js +24 -6
  132. package/dist/renderer/diff.d.ts +2 -2
  133. package/dist/renderer/diff.js +27 -19
  134. package/dist/renderer/differ.d.ts +1 -1
  135. package/dist/renderer/differ.js +9 -9
  136. package/dist/renderer/image.js +19 -19
  137. package/dist/renderer/index.d.ts +6 -6
  138. package/dist/renderer/index.js +163 -93
  139. package/dist/renderer/input.js +66 -48
  140. package/dist/renderer/layout.d.ts +6 -6
  141. package/dist/renderer/layout.js +163 -124
  142. package/dist/renderer/markdown.d.ts +2 -2
  143. package/dist/renderer/markdown.js +173 -54
  144. package/dist/renderer/session-browser.d.ts +2 -2
  145. package/dist/renderer/session-browser.js +19 -21
  146. package/dist/repl.d.ts +5 -5
  147. package/dist/repl.js +300 -198
  148. package/dist/sdk/index.d.ts +8 -7
  149. package/dist/sdk/index.js +59 -42
  150. package/dist/services/AgentDispatcher.d.ts +3 -3
  151. package/dist/services/AgentDispatcher.js +33 -29
  152. package/dist/services/CronExecutor.d.ts +4 -4
  153. package/dist/services/CronExecutor.js +12 -8
  154. package/dist/services/EvaluatorLoop.d.ts +3 -3
  155. package/dist/services/EvaluatorLoop.js +29 -21
  156. package/dist/services/MetaHarness.d.ts +1 -1
  157. package/dist/services/MetaHarness.js +41 -33
  158. package/dist/services/PipelineExecutor.d.ts +1 -1
  159. package/dist/services/PipelineExecutor.js +23 -25
  160. package/dist/services/SkillExtractor.d.ts +43 -0
  161. package/dist/services/SkillExtractor.js +143 -0
  162. package/dist/services/StreamingToolExecutor.d.ts +2 -2
  163. package/dist/services/StreamingToolExecutor.js +11 -7
  164. package/dist/services/a2a.d.ts +8 -8
  165. package/dist/services/a2a.js +44 -34
  166. package/dist/services/agent-messaging.d.ts +33 -15
  167. package/dist/services/agent-messaging.js +65 -13
  168. package/dist/services/cron.js +16 -16
  169. package/dist/tools/AgentTool/index.d.ts +5 -2
  170. package/dist/tools/AgentTool/index.js +35 -15
  171. package/dist/tools/AskUserTool/index.js +1 -1
  172. package/dist/tools/BashTool/index.d.ts +2 -2
  173. package/dist/tools/BashTool/index.js +18 -10
  174. package/dist/tools/CronTool/index.d.ts +2 -2
  175. package/dist/tools/CronTool/index.js +30 -12
  176. package/dist/tools/DiagnosticsTool/index.js +28 -22
  177. package/dist/tools/EnterPlanModeTool/index.js +93 -14
  178. package/dist/tools/EnterWorktreeTool/index.js +7 -3
  179. package/dist/tools/ExitPlanModeTool/index.d.ts +22 -1
  180. package/dist/tools/ExitPlanModeTool/index.js +20 -5
  181. package/dist/tools/ExitWorktreeTool/index.js +11 -4
  182. package/dist/tools/FileEditTool/index.js +3 -5
  183. package/dist/tools/FileReadTool/index.js +16 -10
  184. package/dist/tools/FileWriteTool/index.js +2 -2
  185. package/dist/tools/GlobTool/index.js +5 -9
  186. package/dist/tools/GrepTool/index.d.ts +2 -2
  187. package/dist/tools/GrepTool/index.js +14 -9
  188. package/dist/tools/ImageReadTool/index.js +2 -2
  189. package/dist/tools/KillProcessTool/index.js +11 -7
  190. package/dist/tools/LSTool/index.js +3 -3
  191. package/dist/tools/MemoryTool/index.d.ts +11 -11
  192. package/dist/tools/MemoryTool/index.js +28 -14
  193. package/dist/tools/MonitorTool/index.d.ts +2 -2
  194. package/dist/tools/MonitorTool/index.js +24 -19
  195. package/dist/tools/MultiEditTool/index.js +9 -5
  196. package/dist/tools/NotebookEditTool/index.js +3 -3
  197. package/dist/tools/ParallelAgentTool/index.d.ts +4 -4
  198. package/dist/tools/ParallelAgentTool/index.js +12 -6
  199. package/dist/tools/PipelineTool/index.d.ts +4 -4
  200. package/dist/tools/PipelineTool/index.js +3 -3
  201. package/dist/tools/PowerShellTool/index.js +10 -6
  202. package/dist/tools/RemoteTriggerTool/index.js +8 -4
  203. package/dist/tools/ScheduleWakeupTool/index.d.ts +42 -0
  204. package/dist/tools/ScheduleWakeupTool/index.js +115 -0
  205. package/dist/tools/SendMessageTool/index.js +25 -7
  206. package/dist/tools/SessionSearchTool/index.d.ts +15 -0
  207. package/dist/tools/SessionSearchTool/index.js +36 -0
  208. package/dist/tools/SkillTool/index.d.ts +3 -0
  209. package/dist/tools/SkillTool/index.js +39 -9
  210. package/dist/tools/TaskCreateTool/index.d.ts +2 -2
  211. package/dist/tools/TaskCreateTool/index.js +2 -2
  212. package/dist/tools/TaskGetTool/index.js +2 -2
  213. package/dist/tools/TaskListTool/index.js +3 -5
  214. package/dist/tools/TaskOutputTool/index.js +2 -2
  215. package/dist/tools/TaskStopTool/index.js +3 -3
  216. package/dist/tools/TaskUpdateTool/index.d.ts +4 -4
  217. package/dist/tools/TaskUpdateTool/index.js +2 -2
  218. package/dist/tools/ToolSearchTool/index.js +9 -6
  219. package/dist/tools/WebFetchTool/index.js +1 -1
  220. package/dist/tools/WebSearchTool/index.js +2 -6
  221. package/dist/tools.js +31 -30
  222. package/dist/types/permissions.js +15 -9
  223. package/dist/utils/bash-safety.d.ts +1 -1
  224. package/dist/utils/bash-safety.js +64 -54
  225. package/dist/utils/diff-algorithm.d.ts +3 -3
  226. package/dist/utils/diff-algorithm.js +7 -7
  227. package/dist/utils/fs.js +3 -3
  228. package/dist/utils/safe-env.js +1 -1
  229. package/dist/utils/theme-data.d.ts +1 -1
  230. package/dist/utils/theme-data.js +1 -1
  231. package/dist/utils/theme.d.ts +1 -1
  232. package/dist/utils/theme.js +1 -1
  233. package/dist/utils/tool-summary.d.ts +1 -1
  234. package/dist/utils/tool-summary.js +27 -9
  235. 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'],
32
- 'AI': ['plan', 'review', 'roles', 'agents', 'plugins'],
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();
@@ -269,9 +282,37 @@ register("model", "Switch model (e.g., /model llama3.2 or /model ollama/llama3.2
269
282
  const modelName = model.includes("/") ? model.split("/").slice(1).join("/") : model;
270
283
  return { output: `Switched to ${modelName}.`, handled: true, newModel: modelName };
271
284
  });
272
- register("compact", "Compress conversation history", (_args, ctx) => {
285
+ register("compact", "Compress conversation history (optional: focus keyword or message number)", (args, ctx) => {
286
+ const focus = args.trim();
273
287
  const before = ctx.messages.length;
274
288
  const targetTokens = Math.floor(getContextWindow(ctx.model) * 0.6);
289
+ if (focus && /^\d+$/.test(focus)) {
290
+ // Numeric: compact messages 1-N, keep N+1 onwards
291
+ const cutoff = parseInt(focus, 10);
292
+ if (cutoff < 1 || cutoff >= before) {
293
+ return { output: `Invalid: use 1-${before - 1}`, handled: true };
294
+ }
295
+ const kept = ctx.messages.slice(cutoff);
296
+ return {
297
+ output: `Compacted: removed first ${cutoff} messages, kept ${kept.length}.`,
298
+ handled: true,
299
+ compactedMessages: kept,
300
+ };
301
+ }
302
+ if (focus) {
303
+ // Keyword focus: compress but preserve messages containing the keyword
304
+ const focusLower = focus.toLowerCase();
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);
307
+ const compactedOthers = compressMessages(others, targetTokens);
308
+ const merged = [...compactedOthers, ...preserved].sort((a, b) => a.timestamp - b.timestamp);
309
+ return {
310
+ output: `Compacted with focus "${focus}": ${before} → ${merged.length} messages (preserved ${preserved.length} matching).`,
311
+ handled: true,
312
+ compactedMessages: merged,
313
+ };
314
+ }
315
+ // Default: compress everything
275
316
  const compacted = compressMessages(ctx.messages, targetTokens);
276
317
  const dropped = before - compacted.length;
277
318
  return {
@@ -282,8 +323,8 @@ register("compact", "Compress conversation history", (_args, ctx) => {
282
323
  });
283
324
  register("export", "Export conversation to file", (_args, ctx) => {
284
325
  const lines = ctx.messages
285
- .filter(m => m.role === "user" || m.role === "assistant")
286
- .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}`)
287
328
  .join("\n\n");
288
329
  const filename = `.oh/export-${ctx.sessionId}.md`;
289
330
  try {
@@ -327,7 +368,7 @@ register("memory", "View and search memories in .oh/memory/", (args) => {
327
368
  const term = args.trim().toLowerCase();
328
369
  let files;
329
370
  try {
330
- files = readdirSync(memDir).filter(f => f.endsWith(".md"));
371
+ files = readdirSync(memDir).filter((f) => f.endsWith(".md"));
331
372
  }
332
373
  catch {
333
374
  return { output: "Could not read .oh/memory/", handled: true };
@@ -341,11 +382,13 @@ register("memory", "View and search memories in .oh/memory/", (args) => {
341
382
  try {
342
383
  const content = readFileSync(join(memDir, file), "utf-8");
343
384
  if (content.toLowerCase().includes(term)) {
344
- 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;
345
386
  matches.push(` ${file.padEnd(30)} ${firstLine.slice(0, 50)}`);
346
387
  }
347
388
  }
348
- catch { /* skip */ }
389
+ catch {
390
+ /* skip */
391
+ }
349
392
  }
350
393
  if (matches.length === 0)
351
394
  return { output: `No memories matching "${term}".`, handled: true };
@@ -369,11 +412,11 @@ register("memory", "View and search memories in .oh/memory/", (args) => {
369
412
  });
370
413
  register("companion", "Toggle companion visibility (off/on)", (args) => {
371
414
  const arg = args.trim().toLowerCase();
372
- if (arg === 'off')
373
- return { output: '__COMPANION_OFF__', handled: true };
374
- if (arg === 'on')
375
- return { output: '__COMPANION_ON__', handled: true };
376
- 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 };
377
420
  });
378
421
  register("cybergotchi", "Manage your cybergotchi — feed · pet · rest · status · rename · reset", (args) => {
379
422
  return handleCybergotchiCommand(args);
@@ -384,32 +427,35 @@ register("roles", "List available agent specialization roles", () => {
384
427
  const lines = ["Available agent roles:\n"];
385
428
  for (const role of roles) {
386
429
  lines.push(` ${role.id.padEnd(18)} ${role.name}`);
387
- lines.push(` ${''.padEnd(18)} ${role.description}`);
430
+ lines.push(` ${"".padEnd(18)} ${role.description}`);
388
431
  if (role.suggestedTools?.length) {
389
- lines.push(` ${''.padEnd(18)} Tools: ${role.suggestedTools.join(', ')}`);
432
+ lines.push(` ${"".padEnd(18)} Tools: ${role.suggestedTools.join(", ")}`);
390
433
  }
391
- lines.push('');
434
+ lines.push("");
392
435
  }
393
436
  lines.push("Usage: Agent({ subagent_type: 'code-reviewer', prompt: '...' })");
394
437
  return { output: lines.join("\n"), handled: true };
395
438
  });
396
439
  register("agents", "Discover running openHarness agents on this machine", () => {
397
- const { discoverAgents } = require('../services/a2a.js');
440
+ const { discoverAgents } = require("../services/a2a.js");
398
441
  const agents = discoverAgents();
399
442
  if (agents.length === 0) {
400
- 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
+ };
401
447
  }
402
448
  const lines = [`Running Agents (${agents.length}):\n`];
403
449
  for (const agent of agents) {
404
450
  const age = Math.round((Date.now() - agent.registeredAt) / 60_000);
405
451
  lines.push(` ${agent.name}`);
406
452
  lines.push(` ID: ${agent.id}`);
407
- lines.push(` Provider: ${agent.provider ?? 'unknown'} / ${agent.model ?? 'unknown'}`);
408
- lines.push(` Dir: ${agent.workingDir ?? 'unknown'}`);
409
- 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}` : ""}`);
410
456
  lines.push(` Uptime: ${age}m`);
411
- lines.push(` Caps: ${agent.capabilities.map((c) => c.name).join(', ')}`);
412
- lines.push('');
457
+ lines.push(` Caps: ${agent.capabilities.map((c) => c.name).join(", ")}`);
458
+ lines.push("");
413
459
  }
414
460
  lines.push("Send messages with: Agent({ prompt: 'ask the other agent...', allowed_tools: ['SendMessage'] })");
415
461
  return { output: lines.join("\n"), handled: true };
@@ -447,6 +493,65 @@ register("keys", "Show keyboard shortcuts", () => {
447
493
  shortcuts.push("", " Session:", " /vim Toggle Vim mode", " /browse Interactive session browser", " /theme dark|light Switch theme");
448
494
  return { output: shortcuts.join("\n"), handled: true };
449
495
  });
496
+ register("sandbox", "Show sandbox status and restrictions", () => {
497
+ const { sandboxStatus } = require("../harness/sandbox.js");
498
+ return { output: `${sandboxStatus()}\n\nConfigure in .oh/config.yaml under sandbox:`, handled: true };
499
+ });
500
+ register("effort", "Set reasoning effort level (low/medium/high/max)", (args) => {
501
+ const level = args.trim().toLowerCase();
502
+ const valid = ["low", "medium", "high", "max"];
503
+ if (!valid.includes(level)) {
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
+ };
508
+ }
509
+ return { output: `Effort level set to: ${level}`, handled: true };
510
+ });
511
+ register("btw", "Ask a side question (ephemeral, no tools, not saved to history)", (args) => {
512
+ if (!args.trim()) {
513
+ return { output: "Usage: /btw <your question>", handled: true };
514
+ }
515
+ // Side questions are answered directly without tools or history
516
+ // The output is shown but NOT added to conversation history
517
+ return {
518
+ output: `[btw] ${args.trim()}`,
519
+ handled: false,
520
+ prependToPrompt: `[Side question — answer briefly without using any tools. This is ephemeral and not part of the main conversation.]\n\n${args.trim()}`,
521
+ };
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
+ });
450
555
  register("plan", "Enter plan mode", (_args, _ctx) => {
451
556
  const task = _args.trim();
452
557
  if (!task) {
@@ -455,7 +560,7 @@ register("plan", "Enter plan mode", (_args, _ctx) => {
455
560
  return {
456
561
  output: `[plan mode] ${task}`,
457
562
  handled: false,
458
- 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}`,
459
564
  };
460
565
  });
461
566
  register("review", "Review recent code changes", () => {
@@ -538,39 +643,45 @@ register("doctor", "Run diagnostic health checks", (_args, ctx) => {
538
643
  const ohDir = join(homedir(), ".oh");
539
644
  if (existsSync(ohDir)) {
540
645
  const sessionsDir = join(ohDir, "sessions");
541
- 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;
542
649
  lines.push(` Sessions: ${sessCount} saved`);
543
650
  if (sessCount > 80)
544
651
  issues.push(`${sessCount} saved sessions. Consider cleaning old ones.`);
545
652
  // Memory stats
546
653
  const memDir = join(ohDir, "memory");
547
- 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;
548
655
  lines.push(` Memories: ${memCount} global`);
549
656
  // Cron stats
550
657
  const cronDir = join(ohDir, "crons");
551
- 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;
552
659
  lines.push(` Cron tasks: ${cronCount}`);
553
660
  }
554
661
  }
555
- catch { /* ignore */ }
662
+ catch {
663
+ /* ignore */
664
+ }
556
665
  // Project-level stats
557
666
  try {
558
667
  const projMemDir = join(".oh", "memory");
559
- 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;
560
669
  if (projMemCount > 0)
561
670
  lines.push(` Project mems: ${projMemCount}`);
562
671
  const skillsDir = join(".oh", "skills");
563
- 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;
564
673
  if (skillCount > 0)
565
674
  lines.push(` Skills: ${skillCount}`);
566
675
  }
567
- catch { /* ignore */ }
676
+ catch {
677
+ /* ignore */
678
+ }
568
679
  // Global config
569
680
  const globalCfg = existsSync(join(homedir(), ".oh", "config.yaml"));
570
681
  lines.push(` Global config: ${globalCfg ? "~/.oh/config.yaml ✓" : "not set (optional)"}`);
571
682
  // Verification config
572
683
  try {
573
- const { getVerificationConfig } = require('../harness/verification.js');
684
+ const { getVerificationConfig } = require("../harness/verification.js");
574
685
  const vCfg = getVerificationConfig();
575
686
  if (vCfg?.enabled) {
576
687
  lines.push(` Verification: ✓ (${vCfg.rules.length} rules, mode: ${vCfg.mode})`);
@@ -579,13 +690,15 @@ register("doctor", "Run diagnostic health checks", (_args, ctx) => {
579
690
  lines.push(` Verification: off (no rules detected)`);
580
691
  }
581
692
  }
582
- catch { /* ignore */ }
693
+ catch {
694
+ /* ignore */
695
+ }
583
696
  // Tools
584
697
  lines.push("");
585
- lines.push(` Tools: ${ctx.messages.length > 0 ? 'ready' : 'loaded'}`);
698
+ lines.push(` Tools: ${ctx.messages.length > 0 ? "ready" : "loaded"}`);
586
699
  // Node.js version
587
700
  lines.push(` Node.js: ${process.version}`);
588
- const [major] = process.version.slice(1).split('.').map(Number);
701
+ const [major] = process.version.slice(1).split(".").map(Number);
589
702
  if (major && major < 18)
590
703
  issues.push(`Node.js ${process.version} is below minimum (18+). Upgrade Node.js.`);
591
704
  // Issues summary
@@ -604,25 +717,58 @@ register("doctor", "Run diagnostic health checks", (_args, ctx) => {
604
717
  });
605
718
  register("context", "Show context window usage breakdown", (_args, ctx) => {
606
719
  const ctxWindow = getContextWindow(ctx.model);
607
- const totalTokens = estimateMessageTokens(ctx.messages);
608
- const breakdown = [`Context window: ${ctxWindow.toLocaleString()} tokens\n`];
609
- for (let i = 0; i < ctx.messages.length; i++) {
610
- const msg = ctx.messages[i];
611
- const tokens = Math.round((msg.content?.length ?? 0) / 3.5); // rough per-message estimate
612
- const role = msg.role.padEnd(9);
613
- const pinned = msg.meta?.pinned ? " 📌" : "";
614
- breakdown.push(` #${(i + 1).toString().padStart(3)} ${role} ~${tokens.toLocaleString().padStart(6)} tokens${pinned}`);
720
+ // Categorize messages by type
721
+ let userTokens = 0, assistantTokens = 0, toolTokens = 0, systemTokens = 0;
722
+ for (const msg of ctx.messages) {
723
+ const tokens = Math.ceil((msg.content?.length ?? 0) / 4);
724
+ switch (msg.role) {
725
+ case "user":
726
+ userTokens += tokens;
727
+ break;
728
+ case "assistant":
729
+ assistantTokens += tokens;
730
+ break;
731
+ case "tool":
732
+ toolTokens += tokens;
733
+ break;
734
+ case "system":
735
+ systemTokens += tokens;
736
+ break;
737
+ }
615
738
  }
739
+ const totalTokens = userTokens + assistantTokens + toolTokens + systemTokens;
740
+ const freeTokens = ctxWindow - totalTokens;
616
741
  const usage = totalTokens / ctxWindow;
617
- breakdown.push("");
618
- breakdown.push(`Total: ~${totalTokens.toLocaleString()} tokens (${Math.round(usage * 100)}% of ${ctxWindow.toLocaleString()})`);
619
- breakdown.push(`Compress: at ${Math.round(ctxWindow * 0.8).toLocaleString()} tokens (80%)`);
620
- return { output: breakdown.join("\n"), handled: true };
742
+ // Visual bar (30 chars wide)
743
+ const barWidth = 30;
744
+ const filled = Math.round(usage * barWidth);
745
+ const bar = "\u2588".repeat(filled) + "\u2591".repeat(barWidth - filled);
746
+ const pct = (n) => `${((n / ctxWindow) * 100).toFixed(1)}%`;
747
+ const pad = (s, n) => s.padEnd(n);
748
+ const lines = [
749
+ `Context Window (${ctxWindow.toLocaleString()} tokens):`,
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
+ "",
759
+ ` ${bar} ${Math.round(usage * 100)}%`,
760
+ "",
761
+ ` Messages: ${ctx.messages.length} | Compress at: ${Math.round(ctxWindow * 0.8).toLocaleString()} (80%)`,
762
+ ];
763
+ return { output: lines.join("\n"), handled: true };
621
764
  });
622
765
  register("mcp", "Show MCP server status", () => {
623
766
  const mcp = connectedMcpServers();
624
767
  if (mcp.length === 0) {
625
- 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
+ };
626
772
  }
627
773
  const lines = [`MCP Servers (${mcp.length} connected):\n`];
628
774
  for (const name of mcp) {
@@ -632,11 +778,11 @@ register("mcp", "Show MCP server status", () => {
632
778
  return { output: lines.join("\n"), handled: true };
633
779
  });
634
780
  register("mcp-registry", "Browse and add MCP servers from the curated registry", (args) => {
635
- const { searchRegistry, formatRegistry, generateConfigBlock, MCP_REGISTRY } = require('../mcp/registry.js');
781
+ const { searchRegistry, formatRegistry, generateConfigBlock, MCP_REGISTRY } = require("../mcp/registry.js");
636
782
  const query = args.trim();
637
783
  if (!query) {
638
784
  // Show full registry
639
- 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`;
640
786
  return { output, handled: true };
641
787
  }
642
788
  // Search or show specific server
@@ -649,10 +795,10 @@ register("mcp-registry", "Browse and add MCP servers from the curated registry",
649
795
  const entry = results[0];
650
796
  const config = generateConfigBlock(entry);
651
797
  const envNote = entry.envVars?.length
652
- ? `\n\nRequired environment variables:\n${entry.envVars.map((v) => ` - ${v}`).join('\n')}`
653
- : '';
798
+ ? `\n\nRequired environment variables:\n${entry.envVars.map((v) => ` - ${v}`).join("\n")}`
799
+ : "";
654
800
  return {
655
- 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}`,
656
802
  handled: true,
657
803
  };
658
804
  }
@@ -661,13 +807,13 @@ register("mcp-registry", "Browse and add MCP servers from the curated registry",
661
807
  });
662
808
  function setPinned(args, ctx, pinned) {
663
809
  const idx = parseInt(args.trim(), 10);
664
- if (isNaN(idx) || idx < 1 || idx > ctx.messages.length) {
665
- 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 };
666
812
  }
667
813
  // Immutable update — replace message with updated meta
668
- 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));
669
815
  return {
670
- output: `Message #${idx} ${pinned ? 'pinned' : 'unpinned'}.`,
816
+ output: `Message #${idx} ${pinned ? "pinned" : "unpinned"}.`,
671
817
  handled: true,
672
818
  compactedMessages: updatedMessages,
673
819
  };
@@ -675,52 +821,64 @@ function setPinned(args, ctx, pinned) {
675
821
  register("pin", "Pin a message (survives /compact)", (args, ctx) => setPinned(args, ctx, true));
676
822
  register("unpin", "Unpin a message", (args, ctx) => setPinned(args, ctx, false));
677
823
  register("plugins", "Manage plugins: list, search, install, uninstall, marketplace", (args) => {
678
- const { discoverPlugins, discoverSkills } = require('../harness/plugins.js');
679
- 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");
680
826
  const parts = args.trim().split(/\s+/);
681
- const subcommand = parts[0] ?? '';
682
- const rest = parts.slice(1).join(' ');
827
+ const subcommand = parts[0] ?? "";
828
+ const rest = parts.slice(1).join(" ");
683
829
  // /plugins marketplace add <source>
684
- if (subcommand === 'marketplace') {
830
+ if (subcommand === "marketplace") {
685
831
  const action = parts[1];
686
- const source = parts.slice(2).join(' ');
687
- if (action === 'add' && source) {
832
+ const source = parts.slice(2).join(" ");
833
+ if (action === "add" && source) {
688
834
  const mp = addMarketplace(source);
689
835
  if (mp)
690
836
  return { output: `Added marketplace "${mp.name}" (${mp.plugins.length} plugins)`, handled: true };
691
837
  return { output: `Failed to add marketplace from "${source}"`, handled: true };
692
838
  }
693
- if (action === 'remove' && source) {
694
- 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
+ };
695
844
  }
696
845
  // List marketplaces
697
846
  const mps = listMarketplaces();
698
847
  if (mps.length === 0) {
699
- 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
+ };
700
852
  }
701
853
  const lines = [`Marketplaces (${mps.length}):\n`];
702
854
  for (const mp of mps) {
703
855
  lines.push(` ${mp.name} — ${mp.plugins.length} plugins`);
704
856
  }
705
- return { output: lines.join('\n'), handled: true };
857
+ return { output: lines.join("\n"), handled: true };
706
858
  }
707
859
  // /plugins search <query>
708
- if (subcommand === 'search') {
709
- const query = rest || 'all';
710
- const results = searchMarketplace(query === 'all' ? '' : query);
860
+ if (subcommand === "search") {
861
+ const query = rest || "all";
862
+ const results = searchMarketplace(query === "all" ? "" : query);
711
863
  return { output: formatMarketplaceSearch(results), handled: true };
712
864
  }
713
865
  // /plugins install <name>
714
- if (subcommand === 'install' && rest) {
715
- const [name, marketplace] = rest.split('@');
866
+ if (subcommand === "install" && rest) {
867
+ const [name, marketplace] = rest.split("@");
716
868
  const result = installPlugin(name, marketplace);
717
869
  if (result) {
718
- 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
+ };
719
874
  }
720
- 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
+ };
721
879
  }
722
880
  // /plugins uninstall <name>
723
- if (subcommand === 'uninstall' && rest) {
881
+ if (subcommand === "uninstall" && rest) {
724
882
  return { output: uninstallPlugin(rest) ? `Uninstalled "${rest}"` : `Plugin "${rest}" not found`, handled: true };
725
883
  }
726
884
  // /plugins (no args) — show everything
@@ -730,33 +888,110 @@ register("plugins", "Manage plugins: list, search, install, uninstall, marketpla
730
888
  const lines = [];
731
889
  if (marketplacePlugins.length > 0) {
732
890
  lines.push(formatInstalledPlugins(marketplacePlugins));
733
- lines.push('');
891
+ lines.push("");
734
892
  }
735
893
  if (plugins.length > 0) {
736
894
  lines.push(`Local Plugins (${plugins.length}):`);
737
895
  for (const p of plugins) {
738
- lines.push(` ${p.name}@${p.version} — ${p.description || 'no description'}`);
896
+ lines.push(` ${p.name}@${p.version} — ${p.description || "no description"}`);
739
897
  }
740
- lines.push('');
898
+ lines.push("");
741
899
  }
742
900
  if (skills.length > 0) {
743
901
  lines.push(`Skills (${skills.length}):`);
744
902
  for (const s of skills) {
745
- lines.push(` ${s.source}:${s.name} — ${s.description || ''}`);
903
+ lines.push(` ${s.source}:${s.name} — ${s.description || ""}`);
746
904
  }
747
- lines.push('');
905
+ lines.push("");
748
906
  }
749
907
  if (lines.length === 0) {
750
- 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
+ };
751
955
  }
752
- lines.push('');
753
- lines.push('Commands:');
754
- lines.push(' /plugins search <query> Search marketplaces');
755
- lines.push(' /plugins install <name> Install from marketplace');
756
- lines.push(' /plugins uninstall <name> Remove a plugin');
757
- lines.push(' /plugins marketplace add <src> Add a marketplace');
758
- lines.push(' /plugins marketplace List marketplaces');
759
- return { output: lines.join('\n'), handled: true };
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 };
959
+ }
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 };
760
995
  });
761
996
  // ── Command Parser ──
762
997
  /**
@@ -771,7 +1006,10 @@ export function processSlashCommand(input, context) {
771
1006
  const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1);
772
1007
  // Resolve aliases
773
1008
  const aliases = {
774
- h: 'help', c: 'commit', m: 'model', s: 'status',
1009
+ h: "help",
1010
+ c: "commit",
1011
+ m: "model",
1012
+ s: "status",
775
1013
  };
776
1014
  const resolved = aliases[name] ?? name;
777
1015
  const cmd = commands.get(resolved);