@oriro/orirocli 0.3.4 → 0.3.6

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 (2) hide show
  1. package/dist/cli.js +379 -56
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -4512,6 +4512,95 @@ ${extra}` } : ctx;
4512
4512
  return registry.find(MUX_PROVIDER, MUX_MODEL);
4513
4513
  }
4514
4514
 
4515
+ // src/repl-ui/permission.ts
4516
+ var MODES = ["manual", "accept_edits", "auto", "plan"];
4517
+ var MODE_META = {
4518
+ manual: { label: "Manual", indicator: "\u25CF" },
4519
+ accept_edits: { label: "Accept Edits", indicator: "\u270E" },
4520
+ auto: { label: "Auto", indicator: "\u23F5\u23F5" },
4521
+ plan: { label: "Plan", indicator: "\u25A2" }
4522
+ };
4523
+ var current2 = "manual";
4524
+ function getMode() {
4525
+ return current2;
4526
+ }
4527
+ function setMode(m) {
4528
+ current2 = m;
4529
+ }
4530
+ function cycleMode() {
4531
+ const i = MODES.indexOf(current2);
4532
+ current2 = MODES[(i + 1) % MODES.length];
4533
+ return current2;
4534
+ }
4535
+ var thinking = false;
4536
+ function getThinking() {
4537
+ return thinking;
4538
+ }
4539
+ function toggleThinking() {
4540
+ thinking = !thinking;
4541
+ return thinking;
4542
+ }
4543
+ var THINKING_PRIMER = "Think step by step and plan your approach before acting. Reason carefully and check your work.";
4544
+ function classifyTool(toolName) {
4545
+ const n = toolName.toLowerCase();
4546
+ if (/(^|_)(read|ls|grep|find|glob|inspect|view|cat|list)/.test(n)) return "read";
4547
+ if (/(^|_)(edit|write|apply|patch|create|update|str_replace|multiedit)/.test(n)) return "edit";
4548
+ if (/(^|_)(bash|shell|exec|run|terminal|command|sh)/.test(n)) return "exec";
4549
+ return "other";
4550
+ }
4551
+ function decideTool(opts) {
4552
+ const mode = opts.mode ?? current2;
4553
+ if (opts.guardianBlocked) return { decision: "block", reason: "ORIRO Guardian" };
4554
+ const kind = classifyTool(opts.toolName);
4555
+ if (mode === "plan") {
4556
+ return kind === "read" ? { decision: "allow" } : { decision: "block", reason: "Plan mode is read-only" };
4557
+ }
4558
+ if (mode === "manual") {
4559
+ return kind === "read" ? { decision: "allow" } : { decision: "ask" };
4560
+ }
4561
+ if (mode === "accept_edits") {
4562
+ if (kind === "read" || kind === "edit") return { decision: "allow" };
4563
+ return { decision: "ask" };
4564
+ }
4565
+ return { decision: "allow" };
4566
+ }
4567
+
4568
+ // src/repl-ui/posture-gate.ts
4569
+ var armed = false;
4570
+ function armPostureGate() {
4571
+ armed = true;
4572
+ }
4573
+ function bypassPosture(depthEnv) {
4574
+ const d = Number(depthEnv);
4575
+ return Number.isFinite(d) && d > 0;
4576
+ }
4577
+ function registerPostureGate(pi) {
4578
+ pi.on("tool_call", async (event, ctx) => {
4579
+ if (bypassPosture(process.env.ORIRO_AGENT_DEPTH)) return void 0;
4580
+ const d = decideTool({ toolName: event.toolName, guardianBlocked: false });
4581
+ if (d.decision === "block") {
4582
+ return {
4583
+ block: true,
4584
+ reason: `\u25A2 ${d.reason ?? "blocked by posture"} \u2014 present the plan as text; the user will /approve to execute`
4585
+ };
4586
+ }
4587
+ if (d.decision === "ask" && armed) {
4588
+ if (!ctx.hasUI) {
4589
+ return { block: true, reason: `posture '${getMode()}' requires approval and no UI is available` };
4590
+ }
4591
+ const choice = await ctx.ui.select(
4592
+ `\u25CF Posture '${getMode()}' \u2014 approve this action?
4593
+ Tool: ${event.toolName}
4594
+
4595
+ (Shift+Tab cycles postures; \u23F5\u23F5 Auto stops asking)`,
4596
+ ["Allow once", "Deny"]
4597
+ );
4598
+ return choice === "Allow once" ? void 0 : { block: true, reason: "Denied by user (posture gate)" };
4599
+ }
4600
+ return void 0;
4601
+ });
4602
+ }
4603
+
4515
4604
  // src/head/pi-tool.ts
4516
4605
  import { Type as Type2 } from "typebox";
4517
4606
 
@@ -5977,6 +6066,7 @@ async function assembleOriroSession(opts = {}) {
5977
6066
  // bundled library + the user's own ~/.oriro/skills
5978
6067
  extensionFactories: [
5979
6068
  registerGuardian,
6069
+ registerPostureGate,
5980
6070
  registerHead,
5981
6071
  registerScribe,
5982
6072
  registerOrchestrator,
@@ -6165,32 +6255,37 @@ async function translateOutgoing(text) {
6165
6255
  // src/repl-ui/tui-repl.ts
6166
6256
  import { ProcessTerminal, TUI, Editor, Text, Container } from "@earendil-works/pi-tui";
6167
6257
 
6168
- // src/repl-ui/permission.ts
6169
- var MODES = ["manual", "accept_edits", "auto", "plan"];
6170
- var MODE_META = {
6171
- manual: { label: "Manual", indicator: "\u25CF" },
6172
- accept_edits: { label: "Accept Edits", indicator: "\u270E" },
6173
- auto: { label: "Auto", indicator: "\u23F5\u23F5" },
6174
- plan: { label: "Plan", indicator: "\u25A2" }
6175
- };
6176
- var current2 = "manual";
6177
- function getMode() {
6178
- return current2;
6179
- }
6180
- function cycleMode() {
6181
- const i = MODES.indexOf(current2);
6182
- current2 = MODES[(i + 1) % MODES.length];
6183
- return current2;
6184
- }
6185
- var thinking = false;
6186
- function getThinking() {
6187
- return thinking;
6188
- }
6189
- function toggleThinking() {
6190
- thinking = !thinking;
6191
- return thinking;
6258
+ // src/repl-ui/plan-mode.ts
6259
+ var PLAN_PRIMER = "PLAN MODE \u2014 read-only. Produce a concrete implementation plan for the request below: numbered steps, the exact files to change and how, the commands to run, and the risks. Do NOT make any changes \u2014 no edits, no writes, no commands (write/exec tools are blocked in this mode). Finish with a short 'Verify' list of what will prove the work is correct after execution.";
6260
+ var EXECUTE_PROMPT = "APPROVED: the plan you presented above has been approved by the user. Execute it now, step by step, exactly as written \u2014 implement, run, and verify each step. Do not re-plan and do not ask for approval again; Guardian still protects against dangerous actions.";
6261
+ var prevMode = "manual";
6262
+ var ready = false;
6263
+ function enterPlan(from) {
6264
+ if (from !== "plan") prevMode = from;
6265
+ ready = false;
6266
+ }
6267
+ function notePlanOutput(output) {
6268
+ ready = output.trim().length > 0;
6269
+ return ready;
6270
+ }
6271
+ function approvePlan() {
6272
+ if (!ready) return { ok: false, reason: "no plan is waiting for approval \u2014 /plan <task> first" };
6273
+ ready = false;
6274
+ return { ok: true, restoreMode: prevMode, prompt: EXECUTE_PROMPT };
6275
+ }
6276
+ function rejectPlan() {
6277
+ const had = ready;
6278
+ ready = false;
6279
+ return had;
6280
+ }
6281
+ function parsePlanSlash(line) {
6282
+ const m = /^\/(plan|approve|reject)(?:\s+(\S[\s\S]*))?$/i.exec(line.trim());
6283
+ if (!m) return void 0;
6284
+ const cmd = m[1].toLowerCase();
6285
+ if (cmd === "plan") return m[2] ? { cmd: "plan", task: m[2].trim() } : { cmd: "plan" };
6286
+ if (cmd === "approve") return { cmd: "approve" };
6287
+ return { cmd: "reject" };
6192
6288
  }
6193
- var THINKING_PRIMER = "Think step by step and plan your approach before acting. Reason carefully and check your work.";
6194
6289
 
6195
6290
  // src/repl-ui/verify-actions.ts
6196
6291
  import { existsSync as existsSync15 } from "fs";
@@ -6634,6 +6729,136 @@ async function handleUndo(session) {
6634
6729
  }
6635
6730
  }
6636
6731
 
6732
+ // src/agents/worktree.ts
6733
+ import { execFile } from "child_process";
6734
+ import { promisify } from "util";
6735
+ import { join as join27, basename as basename2 } from "path";
6736
+ var run = promisify(execFile);
6737
+ var MAX_FAN = 4;
6738
+ function parseAgentsSlash(line) {
6739
+ const m = /^\/agents(?:\s+(\S[\s\S]*))?$/i.exec(line.trim());
6740
+ if (!m) return void 0;
6741
+ const rest = m[1]?.trim();
6742
+ if (!rest) return { cmd: "help" };
6743
+ const nx = /^(\d+)x\s+(\S[\s\S]*)$/i.exec(rest);
6744
+ if (nx) {
6745
+ const n = Math.min(Math.max(Number(nx[1]), 1), MAX_FAN);
6746
+ return { cmd: "fan", tasks: Array.from({ length: n }, () => nx[2].trim()) };
6747
+ }
6748
+ const tasks = rest.split("|").map((s) => s.trim()).filter(Boolean).slice(0, MAX_FAN);
6749
+ return tasks.length ? { cmd: "fan", tasks } : { cmd: "help" };
6750
+ }
6751
+ function fanStamp(now) {
6752
+ const p = (n, w = 2) => String(n).padStart(w, "0");
6753
+ return `${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
6754
+ }
6755
+ function fanBranch(stamp, i) {
6756
+ return `oriro/agents/${stamp}-a${i + 1}`;
6757
+ }
6758
+ function fanDir(repoRoot, stamp, i) {
6759
+ return join27(oriroDir(), "worktrees", `${basename2(repoRoot)}-${stamp}-a${i + 1}`);
6760
+ }
6761
+ async function git(cwd, ...args) {
6762
+ try {
6763
+ const { stdout: stdout12 } = await run("git", ["-C", cwd, ...args], { windowsHide: true });
6764
+ return { ok: true, out: stdout12.trim() };
6765
+ } catch (e) {
6766
+ return { ok: false, out: e instanceof Error ? e.message : String(e) };
6767
+ }
6768
+ }
6769
+ async function gitRoot(cwd) {
6770
+ const r = await git(cwd, "rev-parse", "--show-toplevel");
6771
+ return r.ok && r.out ? r.out : void 0;
6772
+ }
6773
+ async function addWorktree(root, dir, branch) {
6774
+ const r = await git(root, "worktree", "add", "-b", branch, dir);
6775
+ return r.ok ? void 0 : r.out;
6776
+ }
6777
+ async function changedFiles(dir) {
6778
+ const r = await git(dir, "status", "--short");
6779
+ return r.ok && r.out ? r.out.split("\n").map((s) => s.trim()).filter(Boolean) : [];
6780
+ }
6781
+ async function removeWorktree(root, dir, branch, force = false) {
6782
+ await git(root, "worktree", "remove", ...force ? ["--force"] : [], dir);
6783
+ if (branch) await git(root, "branch", "-D", branch);
6784
+ }
6785
+ var SNIPPET = 400;
6786
+ function formatFanReport(reports) {
6787
+ const lines = [];
6788
+ for (const r of reports) {
6789
+ lines.push(` \u2692 ${r.role} ${r.ok ? "\u2713" : "\u2717"} \u2014 ${r.task.length > 70 ? `${r.task.slice(0, 70)}\u2026` : r.task}`);
6790
+ const snip = r.output.length > SNIPPET ? `${r.output.slice(0, SNIPPET)}\u2026` : r.output;
6791
+ if (snip) lines.push(...snip.split("\n").map((l) => ` ${l}`));
6792
+ if (r.dir && r.branch && r.changes?.length) {
6793
+ lines.push(` \u270E ${r.changes.length} file${r.changes.length === 1 ? "" : "s"} changed on ${r.branch}`);
6794
+ lines.push(` review: cd "${r.dir}" \xB7 keep: commit there, then \`git merge ${r.branch}\` here`);
6795
+ } else if (r.changes && r.changes.length === 0) {
6796
+ lines.push(" (no file changes \u2014 worktree cleaned up)");
6797
+ }
6798
+ }
6799
+ const kept = reports.filter((r) => r.dir).length;
6800
+ lines.push(` \u2692 fan-out done: ${reports.filter((r) => r.ok).length}/${reports.length} ok${kept ? ` \xB7 ${kept} worktree${kept === 1 ? "" : "s"} kept for review` : ""}`);
6801
+ return lines;
6802
+ }
6803
+
6804
+ // src/agents/fanout.ts
6805
+ var CONCURRENCY = 2;
6806
+ function defFor(role, task) {
6807
+ const now = (/* @__PURE__ */ new Date()).toISOString();
6808
+ return { name: `fan-${role}`, task, createdAt: now, updatedAt: now };
6809
+ }
6810
+ async function runFanout(tasks, cwd) {
6811
+ const capped = tasks.slice(0, MAX_FAN);
6812
+ const root = await gitRoot(cwd);
6813
+ const stamp = fanStamp(/* @__PURE__ */ new Date());
6814
+ const prevDepth = process.env.ORIRO_AGENT_DEPTH;
6815
+ process.env.ORIRO_AGENT_DEPTH = String((Number(prevDepth) || 0) + 1);
6816
+ let reports;
6817
+ try {
6818
+ reports = await runPool(capped.map((task, i) => ({ task, i })), CONCURRENCY, async ({ task, i }) => {
6819
+ const role = `a${i + 1}`;
6820
+ if (!root) {
6821
+ const r2 = await runAgent2(defFor(role, task), { cwd });
6822
+ return { role, task, ok: r2.ok, output: r2.output };
6823
+ }
6824
+ const dir = fanDir(root, stamp, i);
6825
+ const branch = fanBranch(stamp, i);
6826
+ const err = await addWorktree(root, dir, branch);
6827
+ if (err) return { role, task, ok: false, output: `could not create worktree: ${err}` };
6828
+ const r = await runAgent2(defFor(role, task), { cwd: dir });
6829
+ const changes = await changedFiles(dir);
6830
+ if (changes.length === 0) {
6831
+ await removeWorktree(root, dir, branch);
6832
+ return { role, task, ok: r.ok, output: r.output, changes: [] };
6833
+ }
6834
+ return { role, task, ok: r.ok, output: r.output, dir, branch, changes };
6835
+ });
6836
+ } finally {
6837
+ if (prevDepth === void 0) delete process.env.ORIRO_AGENT_DEPTH;
6838
+ else process.env.ORIRO_AGENT_DEPTH = prevDepth;
6839
+ }
6840
+ const lines = formatFanReport(reports);
6841
+ if (!root) lines.unshift(" \u2692 not a git repo \u2014 agents ran in the SAME directory (no worktree isolation)");
6842
+ return lines;
6843
+ }
6844
+
6845
+ // src/repl-ui/slash-agents.ts
6846
+ function isAgentsSlash(slash) {
6847
+ return parseAgentsSlash(slash) !== void 0;
6848
+ }
6849
+ async function handleAgents(line) {
6850
+ const p = parseAgentsSlash(line);
6851
+ if (!p || p.cmd === "help") {
6852
+ return [
6853
+ ` ${accent("/agents")} ${dim("\u2014 parallel sub-agents in isolated git worktrees (results merged here)")}`,
6854
+ ` ${accent("/agents 3x <task>")} ${dim("three agents race the same task")}`,
6855
+ ` ${accent("/agents <task A> | <task B>")} ${dim(`different tasks in parallel (max ${MAX_FAN}; '|' separates tasks)`)}`,
6856
+ ` ${dim("each agent gets its own worktree + branch; clean ones are removed, changed ones kept for review")}`
6857
+ ];
6858
+ }
6859
+ return runFanout(p.tasks, process.cwd());
6860
+ }
6861
+
6637
6862
  // src/repl-ui/tui-repl.ts
6638
6863
  var editorTheme = {
6639
6864
  borderColor: (s) => dim(s),
@@ -6656,6 +6881,7 @@ function footerText() {
6656
6881
  return `${bar} ${think} ${dim("Shift+Tab posture \xB7 Alt+Shift+T thinking \xB7 /exit")}`;
6657
6882
  }
6658
6883
  async function runTuiRepl(session) {
6884
+ armPostureGate();
6659
6885
  const isEnglish3 = getTerminalLanguage().code.toLowerCase().startsWith("en");
6660
6886
  const term = new ProcessTerminal();
6661
6887
  const tui = new TUI(term, true);
@@ -6675,7 +6901,8 @@ async function runTuiRepl(session) {
6675
6901
  };
6676
6902
  const removeListener = tui.addInputListener((data) => {
6677
6903
  if (data === "\x1B[Z") {
6678
- cycleMode();
6904
+ const before = getMode();
6905
+ if (cycleMode() === "plan") enterPlan(before);
6679
6906
  refreshFooter();
6680
6907
  return { consume: true };
6681
6908
  }
@@ -6718,6 +6945,7 @@ async function runTuiRepl(session) {
6718
6945
  ` ${accent("/routers")} pool add\xB7rotate ${accent("/model")} <id\u2026> switch ${accent("/usage")} health ${accent("/trace")} tool+router activity ${accent("/compact")} free context`,
6719
6946
  ` ${accent("/review")} artifacts ${accent("/save")} <n> [path] ${accent("/init")} AGENTS.md ${accent("/skills")} ${accent("/connectors")} ${accent("/voice")}`,
6720
6947
  ` ${accent("/sessions")} list saved ${accent("/undo")} rewind a turn ${dim("resume:")} ${accent("oriro -c")} / ${accent("oriro --resume <id>")}`,
6948
+ ` ${accent("/plan")} <task> plan read-only ${accent("/approve")} execute it ${accent("/reject")} discard ${accent("/agents")} parallel worktree fan-out`,
6721
6949
  ` ${dim("Shift+Tab")} posture ${dim("Alt+Shift+T")} thinking ${accent("/help")} ${accent("/exit")}`
6722
6950
  ].join("\n");
6723
6951
  chat.addChild(new Text(help, 0, 0));
@@ -6798,6 +7026,53 @@ async function runTuiRepl(session) {
6798
7026
  })();
6799
7027
  return;
6800
7028
  }
7029
+ if (isAgentsSlash(slash)) {
7030
+ editor.setText("");
7031
+ const pending = new Text(dim(" \u2692 deploying agents\u2026"), 0, 0);
7032
+ chat.addChild(pending);
7033
+ tui.requestRender();
7034
+ void (async () => {
7035
+ const lines = await handleAgents(text);
7036
+ pending.setText(lines.join("\n"));
7037
+ tui.requestRender();
7038
+ })();
7039
+ return;
7040
+ }
7041
+ const plan = parsePlanSlash(text);
7042
+ let internalPrompt;
7043
+ let turnText = text;
7044
+ if (plan) {
7045
+ if (plan.cmd === "reject") {
7046
+ const had = rejectPlan();
7047
+ chat.addChild(new Text(dim(had ? " \u25A2 plan discarded \u2014 refine the request (still in Plan) or Shift+Tab out" : " \u25A2 nothing to reject \u2014 no plan is waiting"), 0, 0));
7048
+ editor.setText("");
7049
+ tui.requestRender();
7050
+ return;
7051
+ }
7052
+ if (plan.cmd === "approve") {
7053
+ const r = approvePlan();
7054
+ if (!r.ok) {
7055
+ chat.addChild(new Text(dim(` \u25A2 ${r.reason}`), 0, 0));
7056
+ editor.setText("");
7057
+ tui.requestRender();
7058
+ return;
7059
+ }
7060
+ setMode(r.restoreMode);
7061
+ refreshFooter();
7062
+ internalPrompt = r.prompt;
7063
+ } else {
7064
+ enterPlan(getMode());
7065
+ setMode("plan");
7066
+ refreshFooter();
7067
+ if (!plan.task) {
7068
+ chat.addChild(new Text(dim(" \u25A2 Plan mode \u2014 describe the task and I'll plan it (read-only). Then ") + accent("/approve") + dim(" to execute \xB7 ") + accent("/reject") + dim(" to discard."), 0, 0));
7069
+ editor.setText("");
7070
+ tui.requestRender();
7071
+ return;
7072
+ }
7073
+ turnText = plan.task;
7074
+ }
7075
+ }
6801
7076
  if (slash === "/voice") {
6802
7077
  editor.setText("");
6803
7078
  const status = new Text(dim(" \u{1F399} listening\u2026 (needs ffmpeg + the transformers voice peer)"), 0, 0);
@@ -6836,7 +7111,10 @@ async function runTuiRepl(session) {
6836
7111
  busy = true;
6837
7112
  bumpTurns();
6838
7113
  void (async () => {
6839
- let english = await translateIncoming(text);
7114
+ let english = internalPrompt ?? await translateIncoming(turnText);
7115
+ if (getMode() === "plan") english = `${PLAN_PRIMER}
7116
+
7117
+ ${english}`;
6840
7118
  if (getThinking()) english = `${THINKING_PRIMER}
6841
7119
 
6842
7120
  ${english}`;
@@ -6876,6 +7154,9 @@ ${english}`;
6876
7154
  const hint = arts.length ? dim(`
6877
7155
  \u2398 ${arts.length} artifact${arts.length === 1 ? "" : "s"} \u2014 /review to save`) : "";
6878
7156
  streaming.setText((finalText || dim("(no response)")) + (warn ? dim(warn) : "") + hint);
7157
+ if (getMode() === "plan" && notePlanOutput(finalText)) {
7158
+ chat.addChild(new Text(dim(" \u25A2 plan ready \u2014 ") + accent("/approve") + dim(" to execute \xB7 ") + accent("/reject") + dim(" to discard"), 0, 0));
7159
+ }
6879
7160
  tui.requestRender();
6880
7161
  busy = false;
6881
7162
  })();
@@ -6889,7 +7170,7 @@ ${english}`;
6889
7170
  // src/voice/mic.ts
6890
7171
  import { spawn as spawn3 } from "child_process";
6891
7172
  import { tmpdir as tmpdir3 } from "os";
6892
- import { join as join27 } from "path";
7173
+ import { join as join28 } from "path";
6893
7174
  import { existsSync as existsSync18, statSync as statSync4 } from "fs";
6894
7175
  function recorders(outFile, seconds) {
6895
7176
  const dur = String(seconds);
@@ -6911,7 +7192,7 @@ function recorders(outFile, seconds) {
6911
7192
  ];
6912
7193
  }
6913
7194
  async function recordMic(seconds = 6) {
6914
- const outFile = join27(tmpdir3(), `oriro-voice-${process.pid}-${seconds}.wav`);
7195
+ const outFile = join28(tmpdir3(), `oriro-voice-${process.pid}-${seconds}.wav`);
6915
7196
  for (const r of recorders(outFile, seconds)) {
6916
7197
  const okFile = await new Promise((resolve3) => {
6917
7198
  const child = spawn3(r.cmd, r.args, { stdio: "ignore" });
@@ -6983,6 +7264,8 @@ function replHelp() {
6983
7264
  ${dim("Models & routers")} ${accent("/routers")} list\xB7add\xB7rotate the racing pool ${accent("/model")} <id\u2026> switch
6984
7265
  ${dim("This session")} ${accent("/usage")} pool health & turns ${accent("/trace")} activity ${accent("/compact")} free context ${accent("/undo")} rewind a turn
6985
7266
  ${dim("Continuity")} ${accent("/sessions")} list saved sessions ${dim("resume:")} ${accent("oriro -c")} ${dim("or")} ${accent("oriro --resume <id>")}
7267
+ ${dim("Plan loop")} ${accent("/plan")} <task> read-only plan ${accent("/approve")} execute it ${accent("/reject")} discard
7268
+ ${dim("Fan-out")} ${accent("/agents")} <A> | <B> parallel sub-agents in isolated git worktrees
6986
7269
  ${dim("Artifacts")} ${accent("/review")} code/SVG from the last reply ${accent("/save")} <n> [path] write one
6987
7270
  ${dim("Project")} ${accent("/init")} write a starter AGENTS.md ORIRO reads each session
6988
7271
  ${dim("Capabilities")} ${accent("/skills")} ${accent("/connectors")} ${accent("/voice")} speak a turn
@@ -7082,8 +7365,44 @@ async function runReadlineRepl(session) {
7082
7365
  stdout7.write(handleArtifactSlash(line).join("\n") + "\n");
7083
7366
  continue;
7084
7367
  }
7368
+ if (isAgentsSlash(slash)) {
7369
+ stdout7.write((await handleAgents(line)).join("\n") + "\n");
7370
+ continue;
7371
+ }
7372
+ const plan = parsePlanSlash(line);
7373
+ let internalPrompt;
7374
+ let turnText = line;
7375
+ if (plan) {
7376
+ if (plan.cmd === "reject") {
7377
+ stdout7.write(` ${dim(rejectPlan() ? "\u25A2 plan discarded \u2014 refine the request (still in Plan) or /approve a new plan later" : "\u25A2 nothing to reject \u2014 no plan is waiting")}
7378
+ `);
7379
+ continue;
7380
+ }
7381
+ if (plan.cmd === "approve") {
7382
+ const r = approvePlan();
7383
+ if (!r.ok) {
7384
+ stdout7.write(` ${dim(`\u25A2 ${r.reason}`)}
7385
+ `);
7386
+ continue;
7387
+ }
7388
+ setMode(r.restoreMode);
7389
+ internalPrompt = r.prompt;
7390
+ } else {
7391
+ enterPlan(getMode());
7392
+ setMode("plan");
7393
+ if (!plan.task) {
7394
+ stdout7.write(` ${dim("\u25A2 Plan mode \u2014 describe the task and I'll plan it (read-only). Then")} ${accent("/approve")} ${dim("to execute \xB7")} ${accent("/reject")} ${dim("to discard.")}
7395
+ `);
7396
+ continue;
7397
+ }
7398
+ turnText = plan.task;
7399
+ }
7400
+ }
7085
7401
  bumpTurns();
7086
- const english = await translateIncoming(line);
7402
+ let english = internalPrompt ?? await translateIncoming(turnText);
7403
+ if (getMode() === "plan") english = `${PLAN_PRIMER}
7404
+
7405
+ ${english}`;
7087
7406
  noteUserInput(line);
7088
7407
  let out = "";
7089
7408
  const unsub = session.subscribe(
@@ -7107,6 +7426,10 @@ async function runReadlineRepl(session) {
7107
7426
  stdout7.write(`${shown}${phantomFileWarning(shown)}
7108
7427
  ${hint}
7109
7428
  `);
7429
+ if (getMode() === "plan" && notePlanOutput(shown)) {
7430
+ stdout7.write(` ${dim("\u25A2 plan ready \u2014")} ${accent("/approve")} ${dim("to execute \xB7")} ${accent("/reject")} ${dim("to discard")}
7431
+ `);
7432
+ }
7110
7433
  }
7111
7434
  } finally {
7112
7435
  process.removeListener("SIGINT", onSigint);
@@ -7206,7 +7529,7 @@ async function confirmDestructive(what, opts = {}) {
7206
7529
 
7207
7530
  // src/config/store.ts
7208
7531
  import { readFileSync as readFileSync22, writeFileSync as writeFileSync20, mkdirSync as mkdirSync16 } from "fs";
7209
- import { join as join28 } from "path";
7532
+ import { join as join29 } from "path";
7210
7533
  var KEYS = {
7211
7534
  output: {
7212
7535
  desc: "default output format for list commands: text | json | csv",
@@ -7228,7 +7551,7 @@ function validateConfig(key, value) {
7228
7551
  return KEYS[key].validate?.(value) ?? null;
7229
7552
  }
7230
7553
  function file4() {
7231
- return join28(oriroDir(), "config.json");
7554
+ return join29(oriroDir(), "config.json");
7232
7555
  }
7233
7556
  var cache = null;
7234
7557
  function readAll() {
@@ -7831,9 +8154,9 @@ function registerConnectorsCommand(program2) {
7831
8154
 
7832
8155
  // src/channels/config.ts
7833
8156
  import { readFileSync as readFileSync25, writeFileSync as writeFileSync21 } from "fs";
7834
- import { join as join29 } from "path";
8157
+ import { join as join30 } from "path";
7835
8158
  function file5() {
7836
- return join29(oriroDir(), "channels.json");
8159
+ return join30(oriroDir(), "channels.json");
7837
8160
  }
7838
8161
  function readChannels() {
7839
8162
  try {
@@ -7846,10 +8169,10 @@ function readChannels() {
7846
8169
  function saveChannel(cfg) {
7847
8170
  const all = readChannels().filter((c) => c.kind !== cfg.kind);
7848
8171
  all.push(cfg);
7849
- writeFileSync21(join29(ensureOriroDir(), "channels.json"), JSON.stringify(all, null, 2), "utf8");
8172
+ writeFileSync21(join30(ensureOriroDir(), "channels.json"), JSON.stringify(all, null, 2), "utf8");
7850
8173
  }
7851
8174
  function removeChannel(kind) {
7852
- writeFileSync21(join29(ensureOriroDir(), "channels.json"), JSON.stringify(readChannels().filter((c) => c.kind !== kind), null, 2), "utf8");
8175
+ writeFileSync21(join30(ensureOriroDir(), "channels.json"), JSON.stringify(readChannels().filter((c) => c.kind !== kind), null, 2), "utf8");
7853
8176
  }
7854
8177
 
7855
8178
  // src/channels/telegram.ts
@@ -7966,9 +8289,9 @@ async function startDiscord(token) {
7966
8289
  }
7967
8290
 
7968
8291
  // src/channels/whatsapp.ts
7969
- import { join as join30 } from "path";
8292
+ import { join as join31 } from "path";
7970
8293
  function whatsappAuthDir() {
7971
- return join30(oriroDir(), "whatsapp-auth");
8294
+ return join31(oriroDir(), "whatsapp-auth");
7972
8295
  }
7973
8296
  async function startWhatsApp() {
7974
8297
  let baileys;
@@ -8087,7 +8410,7 @@ function registerChannelsCommand(program2) {
8087
8410
 
8088
8411
  // src/commands/skills.ts
8089
8412
  import { existsSync as existsSync21, statSync as statSync5, mkdirSync as mkdirSync17, cpSync, rmSync as rmSync4 } from "fs";
8090
- import { resolve as resolve2, join as join31, basename as basename2, dirname as dirname4 } from "path";
8413
+ import { resolve as resolve2, join as join32, basename as basename3, dirname as dirname4 } from "path";
8091
8414
  function registerSkillsCommand(program2) {
8092
8415
  const skills = program2.command("skills").description("the ORIRO skill library \u2014 bundled + your own");
8093
8416
  skills.command("list").description("show CORE / TAIL skill counts (use --all to list names)").option("-a, --all", "list every skill name").option("-o, --output <fmt>", "output format: text (default) | json | csv").option("-q, --query <expr>", "filter/select: 'field', 'field=value', or 'field=value:selectField'").action(async (opts) => {
@@ -8124,22 +8447,22 @@ function registerSkillsCommand(program2) {
8124
8447
  mkdirSync17(dest, { recursive: true });
8125
8448
  const st = statSync5(src);
8126
8449
  if (st.isDirectory()) {
8127
- if (!existsSync21(join31(src, "SKILL.md"))) die(`no SKILL.md in ${src} \u2014 a skill folder must contain SKILL.md`);
8128
- const name = basename2(src);
8129
- cpSync(src, join31(dest, name), { recursive: true });
8130
- ok(`added skill ${accent(name)} \u2192 ${join31(dest, name)}`);
8131
- } else if (basename2(src).toLowerCase() === "skill.md") {
8132
- const name = basename2(dirname4(src)) || "custom-skill";
8133
- mkdirSync17(join31(dest, name), { recursive: true });
8134
- cpSync(src, join31(dest, name, "SKILL.md"));
8135
- ok(`added skill ${accent(name)} \u2192 ${join31(dest, name)}`);
8450
+ if (!existsSync21(join32(src, "SKILL.md"))) die(`no SKILL.md in ${src} \u2014 a skill folder must contain SKILL.md`);
8451
+ const name = basename3(src);
8452
+ cpSync(src, join32(dest, name), { recursive: true });
8453
+ ok(`added skill ${accent(name)} \u2192 ${join32(dest, name)}`);
8454
+ } else if (basename3(src).toLowerCase() === "skill.md") {
8455
+ const name = basename3(dirname4(src)) || "custom-skill";
8456
+ mkdirSync17(join32(dest, name), { recursive: true });
8457
+ cpSync(src, join32(dest, name, "SKILL.md"));
8458
+ ok(`added skill ${accent(name)} \u2192 ${join32(dest, name)}`);
8136
8459
  } else {
8137
8460
  die("expected a folder containing SKILL.md, or a SKILL.md file");
8138
8461
  }
8139
8462
  info("It loads on next launch \u2014 and is available in chat via /skill.");
8140
8463
  });
8141
8464
  skills.command("remove <name>").description("remove a skill you added").option("-f, --force", "skip the confirmation prompt").action(async (name, opts) => {
8142
- const target = join31(userSkillsDir(), name);
8465
+ const target = join32(userSkillsDir(), name);
8143
8466
  if (!existsSync21(target)) {
8144
8467
  info(`'${name}' is not a user-added skill \u2014 nothing to remove`);
8145
8468
  return;
@@ -8728,7 +9051,7 @@ function registerConfigCommand(program2) {
8728
9051
 
8729
9052
  // src/commands/setup.ts
8730
9053
  import { rmSync as rmSync5 } from "fs";
8731
- import { join as join32 } from "path";
9054
+ import { join as join33 } from "path";
8732
9055
  import { stdin as stdin12, stdout as stdout11 } from "process";
8733
9056
  var MARKERS = [
8734
9057
  "language.json",
@@ -8736,14 +9059,14 @@ var MARKERS = [
8736
9059
  "skills-onboarded.json",
8737
9060
  "connectors-onboarded.json",
8738
9061
  "models-onboarded.json",
8739
- join32("routers", "onboarded.json")
9062
+ join33("routers", "onboarded.json")
8740
9063
  ];
8741
9064
  function registerSetupCommand(program2) {
8742
9065
  program2.command("setup").description("run the guided setup wizard (language \xB7 routers \xB7 connectors \xB7 skills \xB7 avatar)").option("--reset", "clear your settled choices and re-ask every step").action(async (opts) => {
8743
9066
  if (opts.reset) {
8744
9067
  for (const m of MARKERS) {
8745
9068
  try {
8746
- rmSync5(join32(oriroDir(), m), { force: true });
9069
+ rmSync5(join33(oriroDir(), m), { force: true });
8747
9070
  } catch {
8748
9071
  }
8749
9072
  }
@@ -8761,7 +9084,7 @@ function registerSetupCommand(program2) {
8761
9084
 
8762
9085
  // src/commands/import.ts
8763
9086
  import { existsSync as existsSync22, readFileSync as readFileSync27, readdirSync as readdirSync4, statSync as statSync6, cpSync as cpSync2, mkdirSync as mkdirSync18 } from "fs";
8764
- import { join as join33, basename as basename3 } from "path";
9087
+ import { join as join34, basename as basename4 } from "path";
8765
9088
  function registerImportCommand(program2) {
8766
9089
  const imp = program2.command("import").description("migrate from another CLI (MCP servers, skills)");
8767
9090
  imp.command("mcp <file>").description("import MCP servers from a Claude-compatible mcp.json (Guardian-vetted)").action((file6) => {
@@ -8818,11 +9141,11 @@ function registerImportCommand(program2) {
8818
9141
  const dest = userSkillsDir();
8819
9142
  mkdirSync18(dest, { recursive: true });
8820
9143
  heading("Import skills");
8821
- const sources = existsSync22(join33(dir, "SKILL.md")) ? [dir] : readdirSync4(dir).map((e) => join33(dir, e)).filter((p) => statSync6(p).isDirectory() && existsSync22(join33(p, "SKILL.md")));
9144
+ const sources = existsSync22(join34(dir, "SKILL.md")) ? [dir] : readdirSync4(dir).map((e) => join34(dir, e)).filter((p) => statSync6(p).isDirectory() && existsSync22(join34(p, "SKILL.md")));
8822
9145
  let n = 0;
8823
9146
  for (const src of sources) {
8824
- cpSync2(src, join33(dest, basename3(src)), { recursive: true });
8825
- process.stdout.write(` ${fgHex(PALETTE.success, "\u2713")} ${accent(basename3(src))}
9147
+ cpSync2(src, join34(dest, basename4(src)), { recursive: true });
9148
+ process.stdout.write(` ${fgHex(PALETTE.success, "\u2713")} ${accent(basename4(src))}
8826
9149
  `);
8827
9150
  n++;
8828
9151
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oriro/orirocli",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "ORIRO — a free, on-device-friendly terminal AI agent. Built on the Pi agent harness (used as a library).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,7 @@
23
23
  "dev": "tsx src/cli.ts",
24
24
  "build": "tsup",
25
25
  "typecheck": "tsc --noEmit",
26
- "test:unit": "tsx scripts/test-tool-sanitize.ts && tsx scripts/test-guardian.ts && tsx scripts/test-scribe.ts && tsx scripts/test-race.ts && tsx scripts/test-weights.ts && tsx scripts/test-output.ts && tsx scripts/test-connectors.ts && tsx scripts/test-artifacts.ts && tsx scripts/test-project-md.ts && tsx scripts/test-compact.ts && tsx scripts/test-init.ts && tsx scripts/test-sessions.ts",
26
+ "test:unit": "tsx scripts/test-tool-sanitize.ts && tsx scripts/test-guardian.ts && tsx scripts/test-scribe.ts && tsx scripts/test-race.ts && tsx scripts/test-weights.ts && tsx scripts/test-output.ts && tsx scripts/test-connectors.ts && tsx scripts/test-artifacts.ts && tsx scripts/test-project-md.ts && tsx scripts/test-compact.ts && tsx scripts/test-init.ts && tsx scripts/test-sessions.ts && tsx scripts/test-permission.ts && tsx scripts/test-plan-mode.ts && tsx scripts/test-agents-fanout.ts",
27
27
  "smoke": "npm run build && node scripts/smoke.mjs",
28
28
  "prepublishOnly": "npm run build && npm run test:unit && node scripts/smoke.mjs && node scripts/prepublish-check.mjs",
29
29
  "start": "node dist/cli.js"