@matterailab/orbcode 0.1.3 → 0.1.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.
package/README.md CHANGED
@@ -7,6 +7,8 @@ models, the same native tool schemas, the same MatterAI auth backend — rebuilt
7
7
  scratch as an interactive TUI with streaming chat, live thinking display, tool
8
8
  activity rows, edit/command approvals, and todo tracking.
9
9
 
10
+ ![OrbCode CLI screenshot](assets/orbcode-screenshot.webp)
11
+
10
12
  ---
11
13
 
12
14
  ## Table of contents
@@ -144,12 +146,13 @@ The two Axon models are built in; `/model` opens a scroll-and-select picker
144
146
  (`/model <id>` still selects directly). Additional models can be declared via
145
147
  `customModels` in settings.json. The choice persists across sessions.
146
148
 
147
- | id | context | max output | pricing |
148
- |---------------------|---------|------------|--------------------------|
149
- | `axon-code-2-5-pro` | 400k | 64k | $2/M in · $6/M out |
150
- | `axon-code-2-5-mini`| 400k | 64k | free |
149
+ | id | context | max output | pricing |
150
+ | ---------------------- | ------- | ---------- | ------------------ |
151
+ | `axon-eido-3-code-pro` | 400k | 64k | $3/M in · $9/M out |
152
+ | `axon-code-2-5-pro` | 400k | 64k | $2/M in · $6/M out |
153
+ | `axon-code-2-5-mini` | 400k | 64k | free |
151
154
 
152
- `axon-code-2-5-pro` is the default. Both support native JSON tool calls and
155
+ `axon-code-2-5-pro` is the default. All three support native JSON tool calls and
153
156
  image input. Cost comes from the API's usage chunks (`is_byok`-aware) and is
154
157
  shown in the status bar.
155
158
 
@@ -196,35 +199,35 @@ shown in the status bar.
196
199
 
197
200
  ## Slash commands
198
201
 
199
- | command | action |
200
- |------------|--------|
201
- | `/help` | list commands |
202
- | `/model` | scrollable model picker (`/model pro` / `/model mini` / full id selects directly) |
203
- | `/clear` | clear the screen only, like the terminal's `clear` — the conversation and context continue |
204
- | `/new` | start a fresh conversation/session with a clean slate |
205
- | `/resume` | pick a previous session for this directory and continue it (screen is cleared, conversation replayed) |
206
- | `/analytics` | open the MatterAI analytics dashboard (app.matterai.so/orbital) in the browser |
207
- | `/compact` | summarize the conversation and replace history with the summary |
208
- | `/tasks` | print the current task list |
209
- | `/status` | version, model, account, gateway, context usage, cost, approval modes |
210
- | `/cost` | show session cost and fetch account balance |
211
- | `/init` | analyze the codebase and create/improve `AGENTS.md` |
212
- | `/login` | start the browser sign-in flow |
213
- | `/logout` | remove the saved token |
214
- | `/version` | print the CLI version |
215
- | `/exit` | quit |
202
+ | command | action |
203
+ | ------------ | ----------------------------------------------------------------------------------------------------- |
204
+ | `/help` | list commands |
205
+ | `/model` | scrollable model picker (`/model pro` / `/model mini` / full id selects directly) |
206
+ | `/clear` | clear the screen only, like the terminal's `clear` — the conversation and context continue |
207
+ | `/new` | start a fresh conversation/session with a clean slate |
208
+ | `/resume` | pick a previous session for this directory and continue it (screen is cleared, conversation replayed) |
209
+ | `/analytics` | open the MatterAI analytics dashboard (app.matterai.so/orbital) in the browser |
210
+ | `/compact` | summarize the conversation and replace history with the summary |
211
+ | `/tasks` | print the current task list |
212
+ | `/status` | version, model, account, gateway, context usage, cost, approval modes |
213
+ | `/cost` | show session cost and fetch account balance |
214
+ | `/init` | analyze the codebase and create/improve `AGENTS.md` |
215
+ | `/login` | start the browser sign-in flow |
216
+ | `/logout` | remove the saved token |
217
+ | `/version` | print the CLI version |
218
+ | `/exit` | quit |
216
219
 
217
220
  ## Keyboard shortcuts
218
221
 
219
- | key | action |
220
- |-----|--------|
221
- | `Esc` | interrupt the running turn (or cancel login polling / close a menu) |
222
- | `Ctrl+C` | quit |
223
- | `Ctrl+O` | toggle thinking display for the whole transcript (past turns included) |
224
- | `Shift+Tab` | cycle approval mode: ask → accept edits → auto-approve |
225
- | `↑` / `↓` | input history, or navigate any open menu |
226
- | `Ctrl+A` / `Ctrl+E` | start / end of line |
227
- | `Ctrl+U` | clear the input line |
222
+ | key | action |
223
+ | ------------------- | ---------------------------------------------------------------------- |
224
+ | `Esc` | interrupt the running turn (or cancel login polling / close a menu) |
225
+ | `Ctrl+C` | quit |
226
+ | `Ctrl+O` | toggle thinking display for the whole transcript (past turns included) |
227
+ | `Shift+Tab` | cycle approval mode: ask → accept edits → auto-approve |
228
+ | `↑` / `↓` | input history, or navigate any open menu |
229
+ | `Ctrl+A` / `Ctrl+E` | start / end of line |
230
+ | `Ctrl+U` | clear the input line |
228
231
 
229
232
  ## Approvals & safety
230
233
 
@@ -267,22 +270,22 @@ Two kinds of files under `~/.orbcode/`:
267
270
 
268
271
  ```json
269
272
  {
270
- "apiKey": "<token used instead of logging in>",
271
- "baseUrl": "https://my-gateway.example.com/v1",
272
- "model": "my-custom-model",
273
- "autoApproveEdits": false,
274
- "autoApproveSafeCommands": false,
275
- "customModels": [
276
- {
277
- "id": "my-custom-model",
278
- "name": "My Custom Model",
279
- "contextWindow": 128000,
280
- "maxOutputTokens": 32000,
281
- "inputPrice": 0.000001,
282
- "outputPrice": 0.000002
283
- }
284
- ],
285
- "env": { "MY_VAR": "value" }
273
+ "apiKey": "<token used instead of logging in>",
274
+ "baseUrl": "https://my-gateway.example.com/v1",
275
+ "model": "my-custom-model",
276
+ "autoApproveEdits": false,
277
+ "autoApproveSafeCommands": false,
278
+ "customModels": [
279
+ {
280
+ "id": "my-custom-model",
281
+ "name": "My Custom Model",
282
+ "contextWindow": 128000,
283
+ "maxOutputTokens": 32000,
284
+ "inputPrice": 0.000001,
285
+ "outputPrice": 0.000002
286
+ }
287
+ ],
288
+ "env": { "MY_VAR": "value" }
286
289
  }
287
290
  ```
288
291
 
@@ -295,15 +298,15 @@ config.json.
295
298
  Sessions are stored in `~/.orbcode/sessions/<id>.json` and power `/resume`
296
299
  and `--resume <id>`.
297
300
 
298
- | env var | effect |
299
- |---------|--------|
300
- | `ORBCODE_TOKEN` | auth token (overrides everything) |
301
- | `ORBCODE_API_KEY` | same as `apiKey` in settings.json |
302
- | `ORBCODE_BASE_URL` | same as `baseUrl` in settings.json |
303
- | `ORBCODE_MODEL` | model override (what `--model` sets internally) |
304
- | `ORBCODE_CONFIG_DIR` | config directory (default `~/.orbcode`) |
305
- | `ORBCODE_BACKEND_URL` | device-auth backend (default `https://api.matterai.so`) |
306
- | `ORBCODE_APP_URL` | webapp for the authorize page (default `https://app.matterai.so`) |
301
+ | env var | effect |
302
+ | --------------------- | ----------------------------------------------------------------- |
303
+ | `ORBCODE_TOKEN` | auth token (overrides everything) |
304
+ | `ORBCODE_API_KEY` | same as `apiKey` in settings.json |
305
+ | `ORBCODE_BASE_URL` | same as `baseUrl` in settings.json |
306
+ | `ORBCODE_MODEL` | model override (what `--model` sets internally) |
307
+ | `ORBCODE_CONFIG_DIR` | config directory (default `~/.orbcode`) |
308
+ | `ORBCODE_BACKEND_URL` | device-auth backend (default `https://api.matterai.so`) |
309
+ | `ORBCODE_APP_URL` | webapp for the authorize page (default `https://app.matterai.so`) |
307
310
 
308
311
  `autoApproveEdits` / `autoApproveSafeCommands` set the session defaults for the
309
312
  approval prompts (dangerous commands still always prompt); shift+tab cycles
@@ -365,19 +368,19 @@ reset date — the same data as the extension's profile view.
365
368
 
366
369
  Active in the CLI (schemas byte-identical to the extension's `native-tools`):
367
370
 
368
- | tool | executor notes |
369
- |------|----------------|
370
- | `read_file` | line-numbered output (`LINE\|content`, 6-char pad), 1000-line cap, offset/limit |
371
- | `file_edit` | single replacement; unique-match enforcement; `replace_all`; empty `old_string` = whole file |
372
- | `multi_file_edit` | batched edits grouped per file, per-edit OK/FAILED results |
373
- | `file_write` | creates parent dirs, full-content writes |
374
- | `list_files` | optional recursive, ignores node_modules/.git/build dirs, 800-entry cap |
375
- | `search_files` | JS regex search with glob `file_pattern` (picomatch), 300-match cap, binary skip |
376
- | `execute_command` | user's shell, 120s timeout, 30k output cap, optional cwd |
377
- | `web_search` / `web_fetch` | proxied through the MatterAI backend with your token |
378
- | `update_todo_list` | drives the TUI todo panel |
379
- | `ask_followup_question` | interactive menu in the TUI |
380
- | `attempt_completion` | ends the turn with a completion card |
371
+ | tool | executor notes |
372
+ | -------------------------- | -------------------------------------------------------------------------------------------- |
373
+ | `read_file` | line-numbered output (`LINE\|content`, 6-char pad), 1000-line cap, offset/limit |
374
+ | `file_edit` | single replacement; unique-match enforcement; `replace_all`; empty `old_string` = whole file |
375
+ | `multi_file_edit` | batched edits grouped per file, per-edit OK/FAILED results |
376
+ | `file_write` | creates parent dirs, full-content writes |
377
+ | `list_files` | optional recursive, ignores node_modules/.git/build dirs, 800-entry cap |
378
+ | `search_files` | JS regex search with glob `file_pattern` (picomatch), 300-match cap, binary skip |
379
+ | `execute_command` | user's shell, 120s timeout, 30k output cap, optional cwd |
380
+ | `web_search` / `web_fetch` | proxied through the MatterAI backend with your token |
381
+ | `update_todo_list` | drives the TUI todo panel |
382
+ | `ask_followup_question` | interactive menu in the TUI |
383
+ | `attempt_completion` | ends the turn with a completion card |
381
384
 
382
385
  Present in `tools/schemas/` but **inactive** (need IDE services): `codebase_search`,
383
386
  `lsp`, `list_code_definition_names`, `use_skill`, `check_past_chat_memories`,
@@ -417,6 +420,7 @@ Source-of-truth rule: behavior is **ported from the Orbital extension repo**
417
420
  schemas byte-identical rather than editing them here.
418
421
 
419
422
  Backend/web pieces of the device-auth flow live in:
423
+
420
424
  - `gravity-console-backend` → `src/controller/orbcodeAuthController.ts`
421
425
  (+ OAuth state in `router.ts`, callbacks in `authController.ts`)
422
426
  - `gravity-console-webapp` → authorize dialog in `src/App.js`, sign-in q-p
@@ -21,6 +21,17 @@ export const AXON_MODELS = {
21
21
  outputPrice: 0.000006,
22
22
  free: false,
23
23
  },
24
+ "axon-eido-3-code-pro": {
25
+ id: "axon-eido-3-code-pro",
26
+ name: "Axon Eido 3 Pro",
27
+ description: "Axon Eido 3 Pro is the frontier Axon Code model for coding tasks, long running agents and general intelligence, fine-tuned on open source models.",
28
+ contextWindow: 400000,
29
+ maxOutputTokens: 64000,
30
+ supportsImages: true,
31
+ inputPrice: 0.000003,
32
+ outputPrice: 0.000009,
33
+ free: false,
34
+ },
24
35
  };
25
36
  export const DEFAULT_MODEL_ID = "axon-code-2-5-pro";
26
37
  /** Add user-defined models (from settings.json) to the registry. */
package/dist/auth/auth.js CHANGED
@@ -78,7 +78,10 @@ export async function pollDeviceAuth(devicecode) {
78
78
  export async function fetchProfile(token) {
79
79
  const url = getUrlFromToken("https://api.matterai.so/axoncode/profile", token);
80
80
  const response = await fetch(url, {
81
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
81
+ headers: {
82
+ Authorization: `Bearer ${token}`,
83
+ "Content-Type": "application/json",
84
+ },
82
85
  });
83
86
  if (!response.ok) {
84
87
  throw new Error(`Profile request failed (${response.status}). Your token may be invalid or expired.`);
@@ -150,7 +153,9 @@ export async function fetchTaskTitle(taskId, token, maxRetries = 3, retryDelayMs
150
153
  export async function fetchBalance(token, organizationId) {
151
154
  try {
152
155
  const url = getUrlFromToken("https://api.matterai.so/api/profile/balance", token);
153
- const headers = { Authorization: `Bearer ${token}` };
156
+ const headers = {
157
+ Authorization: `Bearer ${token}`,
158
+ };
154
159
  if (organizationId)
155
160
  headers["X-KiloCode-OrganizationId"] = organizationId;
156
161
  const response = await fetch(url, { headers });
package/dist/index.js CHANGED
@@ -4,6 +4,8 @@ import { PRODUCT_NAME, VERSION } from "./branding.js";
4
4
  import { App } from "./ui/App.js";
5
5
  import { runHeadless } from "./headless.js";
6
6
  import { loadSessionById } from "./core/sessions.js";
7
+ import { clearUpdateCache, compareVersions, fetchLatestNpmVersion, getGlobalInstallRoot, getUpdateInfo, isGlobalInstall, runNpmUpdate, } from "./utils/updateCheck.js";
8
+ const PACKAGE_NAME = "@matterailab/orbcode";
7
9
  function printHelp() {
8
10
  console.log(`${PRODUCT_NAME} v${VERSION}
9
11
 
@@ -11,6 +13,7 @@ Usage:
11
13
  orbcode start an interactive session
12
14
  orbcode "<prompt>" start an interactive session with an initial prompt
13
15
  orbcode login sign in to MatterAI
16
+ orbcode update install the latest version from npm
14
17
  orbcode -p "<prompt>" run a single prompt non-interactively (prints only the final response)
15
18
  orbcode -p "…" --yolo non-interactive with auto-approved edits/commands
16
19
  orbcode --model <id> use a specific model for this run
@@ -19,6 +22,32 @@ Usage:
19
22
  orbcode --help show this help
20
23
  `);
21
24
  }
25
+ async function runUpdate() {
26
+ const latest = await fetchLatestNpmVersion(PACKAGE_NAME);
27
+ if (latest === null) {
28
+ console.error("Could not reach the npm registry. Check your network connection and try again.");
29
+ return 1;
30
+ }
31
+ if (compareVersions(VERSION, latest) >= 0) {
32
+ console.log(`${PRODUCT_NAME} v${VERSION} is already up to date (latest: v${latest}).`);
33
+ return 0;
34
+ }
35
+ console.log(`Updating ${PRODUCT_NAME} v${VERSION} → v${latest}…`);
36
+ const global = isGlobalInstall();
37
+ if (!global) {
38
+ const root = await getGlobalInstallRoot();
39
+ console.error(`This CLI was not installed globally (argv[1] = ${process.argv[1] || "<unknown>"}).\n` +
40
+ `To update a local/dev install, run \`npm install -g ${PACKAGE_NAME}@latest\` manually, or \`npm install\` inside the source checkout.` +
41
+ (root ? `\nGlobal install root detected at: ${root}` : ""));
42
+ return 1;
43
+ }
44
+ clearUpdateCache();
45
+ const code = await runNpmUpdate(PACKAGE_NAME);
46
+ if (code === 0) {
47
+ console.log(`Updated to v${latest}. Run \`${PRODUCT_NAME.split(" ")[0].toLowerCase()}\` again to use it.`);
48
+ }
49
+ return code;
50
+ }
22
51
  /** Pop `flag <value>` (accepting -flag and --flag) out of args; returns the value. */
23
52
  function takeFlagValue(args, name) {
24
53
  const index = args.findIndex((a) => a === `--${name}` || a === `-${name}`);
@@ -42,6 +71,10 @@ async function main() {
42
71
  printHelp();
43
72
  return;
44
73
  }
74
+ if (args[0] === "update") {
75
+ const code = await runUpdate();
76
+ process.exit(code);
77
+ }
45
78
  const model = takeFlagValue(args, "model") ?? takeFlagValue(args, "m");
46
79
  if (model) {
47
80
  // loadSettings() treats ORBCODE_MODEL as the highest-precedence override,
@@ -76,7 +109,10 @@ async function main() {
76
109
  process.stdout.write("\x1b[2J\x1b[H");
77
110
  process.stdout.write("\x1b]0;orbcode\x07");
78
111
  }
79
- render(_jsx(App, { initialView: initialView, initialPrompt: initialPrompt, initialSession: initialSession }));
112
+ // Fire-and-forget: a stale or no-network state just means the header shows
113
+ // the "current version" line instead of an upgrade prompt.
114
+ const updateCheckPromise = getUpdateInfo(PACKAGE_NAME, VERSION);
115
+ render(_jsx(App, { initialView: initialView, initialPrompt: initialPrompt, initialSession: initialSession, updateCheck: updateCheckPromise }));
80
116
  }
81
117
  main().catch((error) => {
82
118
  console.error(error);
package/dist/ui/App.js CHANGED
@@ -41,6 +41,28 @@ function setTerminalTitle(title) {
41
41
  process.stdout.write(`\x1b]0;${title}\x07`);
42
42
  }
43
43
  }
44
+ function formatRelativeTime(isoStr) {
45
+ if (!isoStr)
46
+ return "???";
47
+ const now = Date.now();
48
+ const target = new Date(isoStr).getTime();
49
+ if (Number.isNaN(target))
50
+ return "???";
51
+ const diff = target - now;
52
+ if (diff <= 0)
53
+ return "now";
54
+ const sec = Math.floor(diff / 1000);
55
+ const min = Math.floor(sec / 60);
56
+ const hrs = Math.floor(min / 60);
57
+ const days = Math.floor(hrs / 24);
58
+ if (days >= 1)
59
+ return `in ${days} day${days > 1 ? "s" : ""}`;
60
+ if (hrs >= 1)
61
+ return `in ${hrs}h ${min % 60}m`;
62
+ if (min >= 1)
63
+ return `in ${min}m`;
64
+ return "soon";
65
+ }
44
66
  /** Human lines for the /status and /cost usage block (extension profile data). */
45
67
  function usageLines(profile) {
46
68
  const lines = [];
@@ -52,8 +74,22 @@ function usageLines(profile) {
52
74
  if (typeof profile.remainingReviews === "number") {
53
75
  lines.push(`Reviews ${profile.remainingReviews} remaining`);
54
76
  }
55
- if (profile.creditsResetDate)
77
+ if (profile.tieredUsage) {
78
+ const ws = [
79
+ ["5hr", "5-Hour", profile.tieredUsage.fiveHour],
80
+ ["wk", "Weekly", profile.tieredUsage.weekly],
81
+ ["mo", "Monthly", profile.tieredUsage.monthly],
82
+ ];
83
+ for (const [short, label, w] of ws) {
84
+ const remaining = Math.max(0, w.remaining || 0);
85
+ const pct = Math.max(0, Math.min(100, w.percentage || 0));
86
+ const reset = formatRelativeTime(w.resetsAt);
87
+ lines.push(`${label.padEnd(12)} ${remaining.toFixed(1)}/${w.limit.toFixed(1)} left (${pct}% used) · Resets ${reset}`);
88
+ }
89
+ }
90
+ else if (profile.creditsResetDate) {
56
91
  lines.push(`Resets ${profile.creditsResetDate}`);
92
+ }
57
93
  return lines;
58
94
  }
59
95
  const INIT_PROMPT = `Analyze this codebase and create an AGENTS.md file containing:
@@ -106,10 +142,11 @@ let rowCounter = 0;
106
142
  function rowId() {
107
143
  return `row-${rowCounter++}`;
108
144
  }
109
- export function App({ initialView, initialPrompt, initialSession, }) {
145
+ export function App({ initialView, initialPrompt, initialSession, updateCheck, }) {
110
146
  const { exit } = useApp();
111
147
  const [settings, setSettings] = useState(() => loadSettings());
112
148
  const [view, setView] = useState(initialView ?? (getAuthToken(settings) ? "chat" : "login"));
149
+ const [updateInfo, setUpdateInfo] = useState(null);
113
150
  const [rows, setRows] = useState(() => [
114
151
  { kind: "header", id: "header", cwd: process.cwd(), modelName: getModel(loadSettings().model).name },
115
152
  ]);
@@ -120,6 +157,10 @@ export function App({ initialView, initialPrompt, initialSession, }) {
120
157
  const [runningTool, setRunningTool] = useState(null);
121
158
  const [pendingApproval, setPendingApproval] = useState(null);
122
159
  const [pendingFollowup, setPendingFollowup] = useState(null);
160
+ // FIFO queue of messages the user typed while the LLM was still streaming.
161
+ // Drained one-per-turn on each `turn-end` event so multi-step work can
162
+ // keep flowing without making the user wait for the previous response.
163
+ const [queuedMessages, setQueuedMessages] = useState([]);
123
164
  const [modelPickerOpen, setModelPickerOpen] = useState(false);
124
165
  const [resumableSessions, setResumableSessions] = useState(null);
125
166
  const [staticKey, setStaticKey] = useState(0);
@@ -139,7 +180,7 @@ export function App({ initialView, initialPrompt, initialSession, }) {
139
180
  if (!token)
140
181
  return;
141
182
  fetchProfile(token)
142
- .then((profile) => setUsage({ plan: profile.plan, usagePercentage: profile.usagePercentage }))
183
+ .then((profile) => setUsage({ plan: profile.plan, usagePercentage: profile.usagePercentage, tieredUsage: profile.tieredUsage }))
143
184
  .catch(() => { });
144
185
  }, []);
145
186
  const agentRef = useRef(null);
@@ -148,6 +189,26 @@ export function App({ initialView, initialPrompt, initialSession, }) {
148
189
  const textBufferRef = useRef("");
149
190
  // taskId for which a title fetch has already been started (once per task).
150
191
  const titleTaskRef = useRef(null);
192
+ // Mirror of `queuedMessages` for the agent event handler (kept on a ref so
193
+ // we can drain it inside `handleEvent` without re-creating that callback
194
+ // on every keystroke).
195
+ const queueRef = useRef([]);
196
+ const enqueueMessage = useCallback((text) => {
197
+ queueRef.current = [...queueRef.current, text];
198
+ setQueuedMessages(queueRef.current);
199
+ }, []);
200
+ const drainQueue = useCallback(() => {
201
+ if (queueRef.current.length === 0)
202
+ return null;
203
+ const [next, ...rest] = queueRef.current;
204
+ queueRef.current = rest;
205
+ setQueuedMessages(rest);
206
+ return next;
207
+ }, []);
208
+ const clearQueue = useCallback(() => {
209
+ queueRef.current = [];
210
+ setQueuedMessages([]);
211
+ }, []);
151
212
  const maybeFetchTitle = useCallback(() => {
152
213
  const agent = agentRef.current;
153
214
  if (!agent || titleTaskRef.current === agent.taskId)
@@ -227,6 +288,10 @@ export function App({ initialView, initialPrompt, initialSession, }) {
227
288
  case "usage":
228
289
  setContextTokens(event.inputTokens + event.outputTokens);
229
290
  setTotalCost(event.totalCost);
291
+ // A usage chunk arrives once per LLM response, so the plan/usage
292
+ // shown below the chat box stays current mid-turn, not only
293
+ // after the turn finishes.
294
+ refreshUsage();
230
295
  break;
231
296
  case "completion":
232
297
  pushRow({ kind: "completion", text: event.result });
@@ -249,9 +314,25 @@ export function App({ initialView, initialPrompt, initialSession, }) {
249
314
  setBusy(false);
250
315
  maybeFetchTitle();
251
316
  refreshUsage();
317
+ // Pop the next queued message and immediately start a new
318
+ // turn on top of the one that just ended. The agent's
319
+ // `runTurn` is fully resolved at this point (its
320
+ // `finally` emitted this event), so the new turn picks
321
+ // up the up-to-date conversation history. We use
322
+ // `agentRef` directly here to avoid a circular
323
+ // `handleEvent` ↔ `getAgent` reference; the ref is
324
+ // guaranteed populated because `runTurn` set it
325
+ // synchronously before emitting this event.
326
+ const nextQueued = drainQueue();
327
+ if (nextQueued !== null && agentRef.current) {
328
+ pushRow({ kind: "user", text: nextQueued });
329
+ setBusy(true);
330
+ setBusyLabel("Thinking");
331
+ void agentRef.current.runTurn(nextQueued);
332
+ }
252
333
  break;
253
334
  }
254
- }, [pushRow, maybeFetchTitle, refreshUsage]);
335
+ }, [pushRow, maybeFetchTitle, refreshUsage, drainQueue]);
255
336
  const createAgent = useCallback((resume) => {
256
337
  const current = loadSettings();
257
338
  return new Agent({
@@ -324,11 +405,20 @@ export function App({ initialView, initialPrompt, initialSession, }) {
324
405
  if (arg && isValidAxonModel(arg)) {
325
406
  switchModel(arg);
326
407
  }
327
- else if (arg && isValidAxonModel(`axon-code-2-5-${arg}`)) {
328
- switchModel(`axon-code-2-5-${arg}`);
329
- }
330
408
  else if (arg) {
331
- pushRow({ kind: "error", text: `Unknown model "${arg}". Available: ${ids.join(", ")}` });
409
+ // Allow short suffixes like "pro" or "mini" to resolve to the
410
+ // latest matching registered id, so /model pro keeps working
411
+ // as new model generations are added.
412
+ const matches = ids
413
+ .filter((id) => id.endsWith(`-${arg}`))
414
+ .sort()
415
+ .reverse();
416
+ if (matches.length > 0) {
417
+ switchModel(matches[0]);
418
+ }
419
+ else {
420
+ pushRow({ kind: "error", text: `Unknown model "${arg}". Available: ${ids.join(", ")}` });
421
+ }
332
422
  }
333
423
  else {
334
424
  setModelPickerOpen(true);
@@ -342,6 +432,7 @@ export function App({ initialView, initialPrompt, initialSession, }) {
342
432
  break;
343
433
  case "/new":
344
434
  // Drop the agent entirely so the next message starts a fresh session.
435
+ clearQueue();
345
436
  agentRef.current = null;
346
437
  titleTaskRef.current = null;
347
438
  setSessionTitle("");
@@ -465,6 +556,7 @@ export function App({ initialView, initialPrompt, initialSession, }) {
465
556
  setView("login");
466
557
  break;
467
558
  case "/logout": {
559
+ clearQueue();
468
560
  const updated = { ...settings, token: undefined };
469
561
  setSettings(updated);
470
562
  saveSettings(updated);
@@ -478,7 +570,7 @@ export function App({ initialView, initialPrompt, initialSession, }) {
478
570
  default:
479
571
  pushRow({ kind: "error", text: `Unknown command: ${name}. Try /help.` });
480
572
  }
481
- }, [settings, tasks, contextTokens, totalCost, sessionTitle, exit, pushRow, getAgent, switchModel, resetTranscript]);
573
+ }, [settings, tasks, contextTokens, totalCost, sessionTitle, exit, pushRow, getAgent, switchModel, resetTranscript, clearQueue]);
482
574
  const handleSubmit = useCallback((value) => {
483
575
  if (value.startsWith("/")) {
484
576
  handleCommand(value);
@@ -488,11 +580,18 @@ export function App({ initialView, initialPrompt, initialSession, }) {
488
580
  setView("login");
489
581
  return;
490
582
  }
583
+ // While the LLM is still streaming, hold the message in a FIFO
584
+ // queue instead of dropping it on the floor. `handleEvent`'s
585
+ // `turn-end` case drains the queue and starts the next turn.
586
+ if (busy) {
587
+ enqueueMessage(value);
588
+ return;
589
+ }
491
590
  pushRow({ kind: "user", text: value });
492
591
  setBusy(true);
493
592
  setBusyLabel("Thinking");
494
593
  void getAgent().runTurn(value);
495
- }, [settings.token, handleCommand, getAgent, pushRow]);
594
+ }, [settings, busy, handleCommand, enqueueMessage, getAgent, pushRow]);
496
595
  // Apply --resume and an initial prompt (`orbcode "do something"`) on startup.
497
596
  const bootedRef = useRef(false);
498
597
  useEffect(() => {
@@ -505,7 +604,29 @@ export function App({ initialView, initialPrompt, initialSession, }) {
505
604
  handleSubmit(initialPrompt);
506
605
  refreshUsage();
507
606
  }, [initialSession, initialPrompt, handleResume, handleSubmit, refreshUsage]);
607
+ // Resolve the npm version check after first paint so the TUI shows up
608
+ // immediately and the upgrade banner fades in once we know the answer.
609
+ useEffect(() => {
610
+ if (!updateCheck)
611
+ return;
612
+ let cancelled = false;
613
+ updateCheck.then((info) => {
614
+ if (!cancelled)
615
+ setUpdateInfo(info);
616
+ });
617
+ return () => {
618
+ cancelled = true;
619
+ };
620
+ }, [updateCheck]);
508
621
  useInput((input, key) => {
622
+ // Ctrl+D quits from any view (chat, login, busy, picker, approval,
623
+ // followup). InputBox swallows other Ctrl-combos, but this hook is a
624
+ // sibling of InputBox's hook, so it still sees the key.
625
+ if (key.ctrl && input === "d") {
626
+ agentRef.current?.abort();
627
+ exit();
628
+ return;
629
+ }
509
630
  if (key.escape && busy && !pendingApproval && !pendingFollowup) {
510
631
  agentRef.current?.abort();
511
632
  }
@@ -539,13 +660,13 @@ export function App({ initialView, initialPrompt, initialSession, }) {
539
660
  saveSettings(updated);
540
661
  agentRef.current = null;
541
662
  setView("chat");
542
- setUsage({ plan: profile.plan, usagePercentage: profile.usagePercentage });
663
+ setUsage({ plan: profile.plan, usagePercentage: profile.usagePercentage, tieredUsage: profile.tieredUsage });
543
664
  const who = profile.user?.name || profile.user?.email;
544
665
  pushRow({ kind: "info", text: `Signed in${who ? ` as ${who}` : ""}. Ready when you are.` });
545
666
  }, [pushRow]);
546
667
  const taskLines = useMemo(() => tasks.split("\n").map((l) => l.trim()).filter(Boolean), [tasks]);
547
- const inputActive = view === "chat" && !busy && !pendingApproval && !pendingFollowup && !modelPickerOpen && !resumableSessions;
548
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: rows, children: (row) => _jsx(RowView, { row: row }, row.id) }, staticKey), view === "login" ? (_jsx(LoginSection, { onLogin: handleLogin })) : (_jsxs(Box, { flexDirection: "column", children: [streamingReasoning && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: COLORS.thinking, italic: true, children: "\u2726 Thinking\u2026" }), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, italic: true, children: tail(streamingReasoning, expandReasoningRef.current ? 30 : 3) }) })] })), streamingText && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: COLORS.primary, children: "\u25CF " }), streamingText] }) })), runningTool && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: COLORS.warning, children: [formatToolName(runningTool.name), " ", _jsx(Text, { dimColor: true, children: runningTool.summary })] }) })), taskLines.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 1, children: [_jsx(Text, { dimColor: true, bold: true, children: "Tasks" }), taskLines.slice(0, 10).map((line, i) => (_jsx(Text, { dimColor: /^[-*]\s*\[x\]/i.test(line), children: line
668
+ const inputActive = view === "chat" && !pendingApproval && !pendingFollowup && !modelPickerOpen && !resumableSessions;
669
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: rows, children: (row) => _jsx(RowView, { row: row }, row.id) }, staticKey), view === "login" ? (_jsx(LoginSection, { onLogin: handleLogin })) : (_jsxs(Box, { flexDirection: "column", children: [updateInfo?.updateAvailable && updateInfo.latest && (_jsxs(Box, { marginTop: 1, flexDirection: "column", borderStyle: "round", borderColor: COLORS.warning, paddingX: 2, alignSelf: "flex-start", children: [_jsxs(Text, { color: COLORS.warning, bold: true, children: ["\u2191 Update available: v", updateInfo.current, " \u2192 v", updateInfo.latest] }), _jsxs(Text, { children: ["Run ", _jsx(Text, { color: COLORS.accent, children: "orbcode update" }), " to install the latest version, then relaunch."] })] })), streamingReasoning && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: COLORS.thinking, italic: true, children: "\u2726 Thinking\u2026" }), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, italic: true, children: tail(streamingReasoning, expandReasoningRef.current ? 30 : 3) }) })] })), streamingText && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: COLORS.primary, children: "\u25CF " }), streamingText] }) })), runningTool && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: COLORS.warning, children: [formatToolName(runningTool.name), " ", _jsx(Text, { dimColor: true, children: runningTool.summary })] }) })), taskLines.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 1, children: [_jsx(Text, { dimColor: true, bold: true, children: "Tasks" }), taskLines.slice(0, 10).map((line, i) => (_jsx(Text, { dimColor: /^[-*]\s*\[x\]/i.test(line), children: line
549
670
  .replace(/^[-*]\s*\[x\]/i, " ■")
550
671
  .replace(/^[-*]\s*\[-\]/, " ◧")
551
672
  .replace(/^[-*]\s*\[ \]/, " □") }, i))), taskLines.length > 10 && _jsxs(Text, { dimColor: true, children: [" \u2026 ", taskLines.length - 10, " more"] })] })), modelPickerOpen && (_jsx(Box, { marginTop: 1, children: _jsx(ModelPicker, { currentId: settings.model, onSelect: (modelId) => {
@@ -558,12 +679,18 @@ export function App({ initialView, initialPrompt, initialSession, }) {
558
679
  pushRow({ kind: "user", text: answer });
559
680
  pendingFollowup.resolve(answer);
560
681
  setPendingFollowup(null);
561
- } }) })), busy && !pendingApproval && !pendingFollowup && !streamingText && (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: busyLabel }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(InputBox, { active: inputActive, slashCommands: SLASH_COMMANDS, onSubmit: handleSubmit }), _jsx(StatusBar, { modelId: settings.model, contextTokens: contextTokens, totalCost: totalCost, state: busy ? busyLabel : "", approvalMode: approvalMode, busy: busy, title: sessionTitle, plan: usage?.plan, usagePercentage: usage?.usagePercentage })] })] }))] }));
682
+ } }) })), busy && !pendingApproval && !pendingFollowup && !streamingText && (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: busyLabel }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [queuedMessages.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, marginBottom: 1, children: [_jsxs(Text, { dimColor: true, bold: true, children: ["Queue (", queuedMessages.length, ")"] }), queuedMessages.slice(0, 5).map((msg, i) => (_jsxs(Text, { dimColor: true, children: [i + 1, ". ", truncateForQueue(msg).replace(/\n/g, "↵")] }, i))), queuedMessages.length > 5 && (_jsxs(Text, { dimColor: true, children: [" \u2026 ", queuedMessages.length - 5, " more"] }))] })), _jsx(InputBox, { active: inputActive, slashCommands: SLASH_COMMANDS, onSubmit: handleSubmit }), _jsx(StatusBar, { modelId: settings.model, contextTokens: contextTokens, totalCost: totalCost, state: busy ? busyLabel : "", approvalMode: approvalMode, busy: busy, title: sessionTitle, plan: usage?.plan, usagePercentage: usage?.usagePercentage, tieredUsage: usage?.tieredUsage })] })] }))] }));
562
683
  }
563
684
  function tail(text, lines) {
564
685
  const all = text.split("\n").filter((l) => l.trim());
565
686
  return all.slice(-lines).join("\n");
566
687
  }
688
+ const QUEUE_PREVIEW_LIMIT = 80;
689
+ function truncateForQueue(text) {
690
+ if (text.length <= QUEUE_PREVIEW_LIMIT)
691
+ return text;
692
+ return text.slice(0, QUEUE_PREVIEW_LIMIT - 1) + "…";
693
+ }
567
694
  function LoginSection({ onLogin }) {
568
695
  return _jsx(LoginView, { onLogin: onLogin });
569
696
  }
@@ -147,6 +147,14 @@ export function InputBox({ active, slashCommands, onSubmit }) {
147
147
  return;
148
148
  }
149
149
  }
150
+ if (key.shift && key.return) {
151
+ // Shift+Enter inserts a literal newline without submitting.
152
+ setHistoryIndex(-1);
153
+ const next = value.slice(0, cursor) + "\n" + value.slice(cursor);
154
+ setValue(next);
155
+ setCursor((c) => c + 1);
156
+ return;
157
+ }
150
158
  if (key.return) {
151
159
  submit(value);
152
160
  return;
@@ -198,15 +206,11 @@ export function InputBox({ active, slashCommands, onSubmit }) {
198
206
  return;
199
207
  }
200
208
  if (input) {
201
- // Multi-char chunks (paste) can carry an embedded trailing newline,
202
- // which should submit like pressing Enter.
203
- const endsWithNewline = /[\r\n]$/.test(input);
204
- const clean = input.replace(/[\r\n]+$/, "").replace(/\r/g, "\n");
209
+ // Multi-char chunks (paste) are inserted as-is. Newlines inside
210
+ // the chunk are literal newlines, not a submit signal press
211
+ // Enter when you're ready to send.
212
+ const clean = input.replace(/\r\n?/g, "\n");
205
213
  const next = value.slice(0, cursor) + clean + value.slice(cursor);
206
- if (endsWithNewline) {
207
- submit(next);
208
- return;
209
- }
210
214
  setHistoryIndex(-1);
211
215
  setValue(next);
212
216
  setCursor((c) => c + clean.length);
@@ -7,12 +7,51 @@ const MODE_LABELS = {
7
7
  edits: "⏵⏵ accept edits on",
8
8
  auto: "⏵⏵⏵ auto-approve on",
9
9
  };
10
+ function formatRelativeTime(isoStr) {
11
+ if (!isoStr)
12
+ return "???";
13
+ const now = Date.now();
14
+ const target = new Date(isoStr).getTime();
15
+ if (Number.isNaN(target))
16
+ return "???";
17
+ const diff = target - now;
18
+ if (diff <= 0)
19
+ return "now";
20
+ const sec = Math.floor(diff / 1000);
21
+ const min = Math.floor(sec / 60);
22
+ const hrs = Math.floor(min / 60);
23
+ const days = Math.floor(hrs / 24);
24
+ if (days >= 1)
25
+ return `${days}d`;
26
+ if (hrs >= 1)
27
+ return `${hrs}h${min % 60 > 0 ? ` ${min % 60}m` : ""}`;
28
+ if (min >= 1)
29
+ return `${min}m`;
30
+ return "soon";
31
+ }
10
32
  function truncate(text, max) {
11
33
  return text.length > max ? text.slice(0, max - 1) + "…" : text;
12
34
  }
13
- export function StatusBar({ modelId, contextTokens, totalCost, state, approvalMode, busy, title, plan, usagePercentage, }) {
35
+ /**
36
+ * Picks the most constrained window (highest percentage used) and returns a
37
+ * compact string like "5hr 12% · resets in 3h 15m".
38
+ */
39
+ function mostConstrainedSummary(tu) {
40
+ const windows = [
41
+ ["5hr", tu.fiveHour.percentage, tu.fiveHour.resetsAt],
42
+ ["wk", tu.weekly.percentage, tu.weekly.resetsAt],
43
+ ["mo", tu.monthly.percentage, tu.monthly.resetsAt],
44
+ ];
45
+ const [label, pct, resetsAt] = windows.reduce((best, cur) => cur[1] >= best[1] ? cur : best);
46
+ return `${label} ${Math.round(pct)}% · resets ${formatRelativeTime(resetsAt)}`;
47
+ }
48
+ export function StatusBar({ modelId, contextTokens, totalCost, state, approvalMode, busy, title, plan, usagePercentage, tieredUsage, }) {
14
49
  const model = getModel(modelId);
15
50
  const contextPct = Math.min(100, Math.round((contextTokens / model.contextWindow) * 100));
16
- const remaining = typeof usagePercentage === "number" ? Math.max(0, Math.round(100 - usagePercentage)) : undefined;
17
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: approvalMode === "ask" ? undefined : COLORS.warning, children: MODE_LABELS[approvalMode] }), " (shift+tab to cycle)", busy && " · esc to interrupt", state ? _jsxs(Text, { color: COLORS.thinking, children: [" \u00B7 ", state] }) : null] }), _jsxs(Text, { dimColor: true, children: [title ? `${truncate(title, 32)} · ` : "", model.name, " \u00B7 ctx ", contextTokens.toLocaleString(), " (", contextPct, "%)", model.free ? " · free" : ` · $${totalCost.toFixed(4)}`] })] }), (plan || remaining !== undefined) && (_jsx(Box, { justifyContent: "flex-end", children: _jsxs(Text, { dimColor: true, children: [plan ? `${plan} plan` : "", plan && remaining !== undefined ? " · " : "", remaining !== undefined ? (_jsxs(Text, { color: remaining <= 10 ? COLORS.error : undefined, children: [remaining, "% usage remaining"] })) : null] }) }))] }));
51
+ const constrainedLine = tieredUsage
52
+ ? mostConstrainedSummary(tieredUsage)
53
+ : typeof usagePercentage === "number"
54
+ ? `${Math.round(usagePercentage)}% used`
55
+ : null;
56
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: approvalMode === "ask" ? undefined : COLORS.warning, children: MODE_LABELS[approvalMode] }), " (shift+tab to cycle)", busy && " · esc to interrupt", state ? _jsxs(Text, { color: COLORS.thinking, children: [" \u00B7 ", state] }) : null] }), _jsxs(Text, { dimColor: true, children: [title ? `${truncate(title, 32)} · ` : "", model.name, " \u00B7 ctx ", contextTokens.toLocaleString(), " (", contextPct, "%)", model.free ? " · free" : ` · $${totalCost.toFixed(4)}`] })] }), (plan || constrainedLine) && (_jsx(Box, { justifyContent: "flex-end", children: _jsxs(Text, { dimColor: true, children: [plan && constrainedLine ? " · " : "", constrainedLine] }) }))] }));
18
57
  }
@@ -0,0 +1,162 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { getConfigDir } from "../config/settings.js";
6
+ const execFileAsync = promisify(execFile);
7
+ /** Network timeout when hitting the npm registry. Kept short so it never blocks startup. */
8
+ const REGISTRY_TIMEOUT_MS = 3_000;
9
+ /** Don't re-hit npm more than once per TTL window. */
10
+ const CACHE_TTL_MS = 60 * 60 * 1_000; // 1 hour
11
+ /** Hit the npm registry to learn what the latest published version is. */
12
+ export async function fetchLatestNpmVersion(pkg, timeoutMs = REGISTRY_TIMEOUT_MS) {
13
+ // encodeURIComponent turns "@matterailab/orbcode" into "@matterailab%2Forbcode",
14
+ // which is the form the registry URL expects for scoped packages.
15
+ const url = `https://registry.npmjs.org/${encodeURIComponent(pkg)}/latest`;
16
+ const controller = new AbortController();
17
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
18
+ try {
19
+ const res = await fetch(url, {
20
+ signal: controller.signal,
21
+ headers: { accept: "application/json" },
22
+ });
23
+ if (!res.ok)
24
+ return null;
25
+ const data = (await res.json());
26
+ return typeof data.version === "string" ? data.version : null;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ finally {
32
+ clearTimeout(timer);
33
+ }
34
+ }
35
+ /**
36
+ * Compare two semver strings (X.Y.Z, optionally with a pre-release suffix).
37
+ * Returns -1 if a < b, 0 if equal, 1 if a > b. Non-numeric segments fall back
38
+ * to lexicographic compare so "0.1.4-beta" sorts before "0.1.4".
39
+ */
40
+ export function compareVersions(a, b) {
41
+ const [aMain, aPre] = splitVersion(a);
42
+ const [bMain, bPre] = splitVersion(b);
43
+ for (let i = 0; i < 3; i++) {
44
+ const ai = aMain[i] ?? 0;
45
+ const bi = bMain[i] ?? 0;
46
+ if (ai !== bi)
47
+ return ai < bi ? -1 : 1;
48
+ }
49
+ // A version with no pre-release is greater than one with one.
50
+ if (aPre === bPre)
51
+ return 0;
52
+ if (aPre === "")
53
+ return 1;
54
+ if (bPre === "")
55
+ return -1;
56
+ return aPre < bPre ? -1 : 1;
57
+ }
58
+ function splitVersion(v) {
59
+ const [main, pre = ""] = v.split("-", 2);
60
+ const parts = main.split(".").map((p) => {
61
+ const n = Number.parseInt(p, 10);
62
+ return Number.isFinite(n) ? n : 0;
63
+ });
64
+ while (parts.length < 3)
65
+ parts.push(0);
66
+ return [parts, pre];
67
+ }
68
+ function getCachePath() {
69
+ return path.join(getConfigDir(), "update-check.json");
70
+ }
71
+ function readCache() {
72
+ try {
73
+ const raw = fs.readFileSync(getCachePath(), "utf8");
74
+ const parsed = JSON.parse(raw);
75
+ if (typeof parsed.checkedAt !== "number")
76
+ return null;
77
+ return { checkedAt: parsed.checkedAt, latest: parsed.latest ?? null };
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ function writeCache(latest) {
84
+ try {
85
+ const dir = getConfigDir();
86
+ fs.mkdirSync(dir, { recursive: true });
87
+ fs.writeFileSync(getCachePath(), JSON.stringify({ checkedAt: Date.now(), latest }), { mode: 0o600 });
88
+ }
89
+ catch {
90
+ // best-effort; a missing cache just means we'll hit the registry again next launch
91
+ }
92
+ }
93
+ /**
94
+ * Resolve whether a newer version is available, using a short-lived cache so
95
+ * we don't hammer the npm registry on every launch.
96
+ */
97
+ export async function getUpdateInfo(pkg, current) {
98
+ const cached = readCache();
99
+ if (cached && Date.now() - cached.checkedAt < CACHE_TTL_MS) {
100
+ return makeResult(current, cached.latest);
101
+ }
102
+ const latest = await fetchLatestNpmVersion(pkg);
103
+ if (latest !== null)
104
+ writeCache(latest);
105
+ return makeResult(current, latest);
106
+ }
107
+ function makeResult(current, latest) {
108
+ if (latest === null) {
109
+ return { current, latest: null, updateAvailable: false, unknown: true };
110
+ }
111
+ return {
112
+ current,
113
+ latest,
114
+ updateAvailable: compareVersions(current, latest) < 0,
115
+ unknown: false,
116
+ };
117
+ }
118
+ /** Invalidate the cached "latest" so the next launch re-checks. */
119
+ export function clearUpdateCache() {
120
+ try {
121
+ fs.unlinkSync(getCachePath());
122
+ }
123
+ catch {
124
+ // ignore
125
+ }
126
+ }
127
+ /** Run `npm install -g <pkg>@latest` and return the exit code (0 = success). */
128
+ export function runNpmUpdate(pkg) {
129
+ return new Promise((resolve) => {
130
+ // On Windows, npm is a .cmd shim; spawning it directly fails without shell:true.
131
+ const isWin = process.platform === "win32";
132
+ const child = spawn("npm", ["install", "-g", `${pkg}@latest`], {
133
+ stdio: "inherit",
134
+ shell: isWin,
135
+ });
136
+ child.on("error", () => resolve(1));
137
+ child.on("close", (code) => resolve(typeof code === "number" ? code : 1));
138
+ });
139
+ }
140
+ /**
141
+ * Detect whether the running CLI is inside a global npm install, so the
142
+ * update command can surface a friendlier message for local/dev installs.
143
+ */
144
+ export function isGlobalInstall() {
145
+ // When installed via `npm i -g @matterailab/orbcode`, the entrypoint lives
146
+ // somewhere like <global root>/node_modules/@matterailab/orbcode/...
147
+ const here = process.argv[1] || "";
148
+ return /node_modules[\\/]@matterailab[\\/]orbcode/.test(here);
149
+ }
150
+ /** Best-effort: run `npm root -g` so we can show the install location. */
151
+ export async function getGlobalInstallRoot() {
152
+ try {
153
+ const { stdout } = await execFileAsync("npm", ["root", "-g"], {
154
+ shell: process.platform === "win32",
155
+ });
156
+ const trimmed = stdout.trim();
157
+ return trimmed ? trimmed : null;
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matterailab/orbcode",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "OrbCode CLI — agentic coding in your terminal, powered by Axon models by MatterAI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,7 +40,7 @@
40
40
  "ink": "^5.2.1",
41
41
  "open": "^10.1.0",
42
42
  "openai": "^4.78.0",
43
- "picomatch": "^4.0.2",
43
+ "picomatch": "^4.0.4",
44
44
  "react": "^18.3.1"
45
45
  },
46
46
  "devDependencies": {