@oriro/orirocli 0.3.4 → 0.3.5

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 +199 -28
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -4512,6 +4512,90 @@ ${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 registerPostureGate(pi) {
4574
+ pi.on("tool_call", async (event, ctx) => {
4575
+ const d = decideTool({ toolName: event.toolName, guardianBlocked: false });
4576
+ if (d.decision === "block") {
4577
+ return {
4578
+ block: true,
4579
+ reason: `\u25A2 ${d.reason ?? "blocked by posture"} \u2014 present the plan as text; the user will /approve to execute`
4580
+ };
4581
+ }
4582
+ if (d.decision === "ask" && armed) {
4583
+ if (!ctx.hasUI) {
4584
+ return { block: true, reason: `posture '${getMode()}' requires approval and no UI is available` };
4585
+ }
4586
+ const choice = await ctx.ui.select(
4587
+ `\u25CF Posture '${getMode()}' \u2014 approve this action?
4588
+ Tool: ${event.toolName}
4589
+
4590
+ (Shift+Tab cycles postures; \u23F5\u23F5 Auto stops asking)`,
4591
+ ["Allow once", "Deny"]
4592
+ );
4593
+ return choice === "Allow once" ? void 0 : { block: true, reason: "Denied by user (posture gate)" };
4594
+ }
4595
+ return void 0;
4596
+ });
4597
+ }
4598
+
4515
4599
  // src/head/pi-tool.ts
4516
4600
  import { Type as Type2 } from "typebox";
4517
4601
 
@@ -5977,6 +6061,7 @@ async function assembleOriroSession(opts = {}) {
5977
6061
  // bundled library + the user's own ~/.oriro/skills
5978
6062
  extensionFactories: [
5979
6063
  registerGuardian,
6064
+ registerPostureGate,
5980
6065
  registerHead,
5981
6066
  registerScribe,
5982
6067
  registerOrchestrator,
@@ -6165,32 +6250,37 @@ async function translateOutgoing(text) {
6165
6250
  // src/repl-ui/tui-repl.ts
6166
6251
  import { ProcessTerminal, TUI, Editor, Text, Container } from "@earendil-works/pi-tui";
6167
6252
 
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;
6253
+ // src/repl-ui/plan-mode.ts
6254
+ 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.";
6255
+ 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.";
6256
+ var prevMode = "manual";
6257
+ var ready = false;
6258
+ function enterPlan(from) {
6259
+ if (from !== "plan") prevMode = from;
6260
+ ready = false;
6261
+ }
6262
+ function notePlanOutput(output) {
6263
+ ready = output.trim().length > 0;
6264
+ return ready;
6265
+ }
6266
+ function approvePlan() {
6267
+ if (!ready) return { ok: false, reason: "no plan is waiting for approval \u2014 /plan <task> first" };
6268
+ ready = false;
6269
+ return { ok: true, restoreMode: prevMode, prompt: EXECUTE_PROMPT };
6270
+ }
6271
+ function rejectPlan() {
6272
+ const had = ready;
6273
+ ready = false;
6274
+ return had;
6275
+ }
6276
+ function parsePlanSlash(line) {
6277
+ const m = /^\/(plan|approve|reject)(?:\s+(\S[\s\S]*))?$/i.exec(line.trim());
6278
+ if (!m) return void 0;
6279
+ const cmd = m[1].toLowerCase();
6280
+ if (cmd === "plan") return m[2] ? { cmd: "plan", task: m[2].trim() } : { cmd: "plan" };
6281
+ if (cmd === "approve") return { cmd: "approve" };
6282
+ return { cmd: "reject" };
6192
6283
  }
6193
- var THINKING_PRIMER = "Think step by step and plan your approach before acting. Reason carefully and check your work.";
6194
6284
 
6195
6285
  // src/repl-ui/verify-actions.ts
6196
6286
  import { existsSync as existsSync15 } from "fs";
@@ -6656,6 +6746,7 @@ function footerText() {
6656
6746
  return `${bar} ${think} ${dim("Shift+Tab posture \xB7 Alt+Shift+T thinking \xB7 /exit")}`;
6657
6747
  }
6658
6748
  async function runTuiRepl(session) {
6749
+ armPostureGate();
6659
6750
  const isEnglish3 = getTerminalLanguage().code.toLowerCase().startsWith("en");
6660
6751
  const term = new ProcessTerminal();
6661
6752
  const tui = new TUI(term, true);
@@ -6675,7 +6766,8 @@ async function runTuiRepl(session) {
6675
6766
  };
6676
6767
  const removeListener = tui.addInputListener((data) => {
6677
6768
  if (data === "\x1B[Z") {
6678
- cycleMode();
6769
+ const before = getMode();
6770
+ if (cycleMode() === "plan") enterPlan(before);
6679
6771
  refreshFooter();
6680
6772
  return { consume: true };
6681
6773
  }
@@ -6718,6 +6810,7 @@ async function runTuiRepl(session) {
6718
6810
  ` ${accent("/routers")} pool add\xB7rotate ${accent("/model")} <id\u2026> switch ${accent("/usage")} health ${accent("/trace")} tool+router activity ${accent("/compact")} free context`,
6719
6811
  ` ${accent("/review")} artifacts ${accent("/save")} <n> [path] ${accent("/init")} AGENTS.md ${accent("/skills")} ${accent("/connectors")} ${accent("/voice")}`,
6720
6812
  ` ${accent("/sessions")} list saved ${accent("/undo")} rewind a turn ${dim("resume:")} ${accent("oriro -c")} / ${accent("oriro --resume <id>")}`,
6813
+ ` ${accent("/plan")} <task> plan read-only ${accent("/approve")} execute it ${accent("/reject")} discard`,
6721
6814
  ` ${dim("Shift+Tab")} posture ${dim("Alt+Shift+T")} thinking ${accent("/help")} ${accent("/exit")}`
6722
6815
  ].join("\n");
6723
6816
  chat.addChild(new Text(help, 0, 0));
@@ -6798,6 +6891,41 @@ async function runTuiRepl(session) {
6798
6891
  })();
6799
6892
  return;
6800
6893
  }
6894
+ const plan = parsePlanSlash(text);
6895
+ let internalPrompt;
6896
+ let turnText = text;
6897
+ if (plan) {
6898
+ if (plan.cmd === "reject") {
6899
+ const had = rejectPlan();
6900
+ 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));
6901
+ editor.setText("");
6902
+ tui.requestRender();
6903
+ return;
6904
+ }
6905
+ if (plan.cmd === "approve") {
6906
+ const r = approvePlan();
6907
+ if (!r.ok) {
6908
+ chat.addChild(new Text(dim(` \u25A2 ${r.reason}`), 0, 0));
6909
+ editor.setText("");
6910
+ tui.requestRender();
6911
+ return;
6912
+ }
6913
+ setMode(r.restoreMode);
6914
+ refreshFooter();
6915
+ internalPrompt = r.prompt;
6916
+ } else {
6917
+ enterPlan(getMode());
6918
+ setMode("plan");
6919
+ refreshFooter();
6920
+ if (!plan.task) {
6921
+ 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));
6922
+ editor.setText("");
6923
+ tui.requestRender();
6924
+ return;
6925
+ }
6926
+ turnText = plan.task;
6927
+ }
6928
+ }
6801
6929
  if (slash === "/voice") {
6802
6930
  editor.setText("");
6803
6931
  const status = new Text(dim(" \u{1F399} listening\u2026 (needs ffmpeg + the transformers voice peer)"), 0, 0);
@@ -6836,7 +6964,10 @@ async function runTuiRepl(session) {
6836
6964
  busy = true;
6837
6965
  bumpTurns();
6838
6966
  void (async () => {
6839
- let english = await translateIncoming(text);
6967
+ let english = internalPrompt ?? await translateIncoming(turnText);
6968
+ if (getMode() === "plan") english = `${PLAN_PRIMER}
6969
+
6970
+ ${english}`;
6840
6971
  if (getThinking()) english = `${THINKING_PRIMER}
6841
6972
 
6842
6973
  ${english}`;
@@ -6876,6 +7007,9 @@ ${english}`;
6876
7007
  const hint = arts.length ? dim(`
6877
7008
  \u2398 ${arts.length} artifact${arts.length === 1 ? "" : "s"} \u2014 /review to save`) : "";
6878
7009
  streaming.setText((finalText || dim("(no response)")) + (warn ? dim(warn) : "") + hint);
7010
+ if (getMode() === "plan" && notePlanOutput(finalText)) {
7011
+ chat.addChild(new Text(dim(" \u25A2 plan ready \u2014 ") + accent("/approve") + dim(" to execute \xB7 ") + accent("/reject") + dim(" to discard"), 0, 0));
7012
+ }
6879
7013
  tui.requestRender();
6880
7014
  busy = false;
6881
7015
  })();
@@ -6983,6 +7117,7 @@ function replHelp() {
6983
7117
  ${dim("Models & routers")} ${accent("/routers")} list\xB7add\xB7rotate the racing pool ${accent("/model")} <id\u2026> switch
6984
7118
  ${dim("This session")} ${accent("/usage")} pool health & turns ${accent("/trace")} activity ${accent("/compact")} free context ${accent("/undo")} rewind a turn
6985
7119
  ${dim("Continuity")} ${accent("/sessions")} list saved sessions ${dim("resume:")} ${accent("oriro -c")} ${dim("or")} ${accent("oriro --resume <id>")}
7120
+ ${dim("Plan loop")} ${accent("/plan")} <task> read-only plan ${accent("/approve")} execute it ${accent("/reject")} discard
6986
7121
  ${dim("Artifacts")} ${accent("/review")} code/SVG from the last reply ${accent("/save")} <n> [path] write one
6987
7122
  ${dim("Project")} ${accent("/init")} write a starter AGENTS.md ORIRO reads each session
6988
7123
  ${dim("Capabilities")} ${accent("/skills")} ${accent("/connectors")} ${accent("/voice")} speak a turn
@@ -7082,8 +7217,40 @@ async function runReadlineRepl(session) {
7082
7217
  stdout7.write(handleArtifactSlash(line).join("\n") + "\n");
7083
7218
  continue;
7084
7219
  }
7220
+ const plan = parsePlanSlash(line);
7221
+ let internalPrompt;
7222
+ let turnText = line;
7223
+ if (plan) {
7224
+ if (plan.cmd === "reject") {
7225
+ 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")}
7226
+ `);
7227
+ continue;
7228
+ }
7229
+ if (plan.cmd === "approve") {
7230
+ const r = approvePlan();
7231
+ if (!r.ok) {
7232
+ stdout7.write(` ${dim(`\u25A2 ${r.reason}`)}
7233
+ `);
7234
+ continue;
7235
+ }
7236
+ setMode(r.restoreMode);
7237
+ internalPrompt = r.prompt;
7238
+ } else {
7239
+ enterPlan(getMode());
7240
+ setMode("plan");
7241
+ if (!plan.task) {
7242
+ 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.")}
7243
+ `);
7244
+ continue;
7245
+ }
7246
+ turnText = plan.task;
7247
+ }
7248
+ }
7085
7249
  bumpTurns();
7086
- const english = await translateIncoming(line);
7250
+ let english = internalPrompt ?? await translateIncoming(turnText);
7251
+ if (getMode() === "plan") english = `${PLAN_PRIMER}
7252
+
7253
+ ${english}`;
7087
7254
  noteUserInput(line);
7088
7255
  let out = "";
7089
7256
  const unsub = session.subscribe(
@@ -7107,6 +7274,10 @@ async function runReadlineRepl(session) {
7107
7274
  stdout7.write(`${shown}${phantomFileWarning(shown)}
7108
7275
  ${hint}
7109
7276
  `);
7277
+ if (getMode() === "plan" && notePlanOutput(shown)) {
7278
+ stdout7.write(` ${dim("\u25A2 plan ready \u2014")} ${accent("/approve")} ${dim("to execute \xB7")} ${accent("/reject")} ${dim("to discard")}
7279
+ `);
7280
+ }
7110
7281
  }
7111
7282
  } finally {
7112
7283
  process.removeListener("SIGINT", onSigint);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oriro/orirocli",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
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",
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"