@f5-sales-demo/xcsh 21.0.0 → 21.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "21.0.0",
4
+ "version": "21.1.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -61,13 +61,13 @@
61
61
  },
62
62
  "dependencies": {
63
63
  "@agentclientprotocol/sdk": "1.3.0",
64
- "@f5-sales-demo/pi-agent-core": "21.0.0",
65
- "@f5-sales-demo/pi-ai": "21.0.0",
66
- "@f5-sales-demo/pi-natives": "21.0.0",
67
- "@f5-sales-demo/pi-resource-management": "21.0.0",
68
- "@f5-sales-demo/pi-tui": "21.0.0",
69
- "@f5-sales-demo/pi-utils": "21.0.0",
70
- "@f5-sales-demo/xcsh-stats": "21.0.0",
64
+ "@f5-sales-demo/pi-agent-core": "21.1.0",
65
+ "@f5-sales-demo/pi-ai": "21.1.0",
66
+ "@f5-sales-demo/pi-natives": "21.1.0",
67
+ "@f5-sales-demo/pi-resource-management": "21.1.0",
68
+ "@f5-sales-demo/pi-tui": "21.1.0",
69
+ "@f5-sales-demo/pi-utils": "21.1.0",
70
+ "@f5-sales-demo/xcsh-stats": "21.1.0",
71
71
  "@mozilla/readability": "^0.6",
72
72
  "@sinclair/typebox": "^0.34",
73
73
  "@xterm/headless": "^6.0",
package/src/cli/args.ts CHANGED
@@ -17,6 +17,8 @@ import {
17
17
 
18
18
  export type Mode = "text" | "json" | "rpc" | "acp";
19
19
 
20
+ export type ExtensionFlagRegistry = ReadonlyMap<string, { type: "boolean" | "string" }>;
21
+
20
22
  export interface Args {
21
23
  cwd?: string;
22
24
  allowHome?: boolean;
@@ -224,7 +226,97 @@ function isValueToken(token: string | undefined): token is string {
224
226
  return token !== undefined && !token.startsWith("-") && !token.startsWith("@");
225
227
  }
226
228
 
227
- export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "boolean" | "string" }>): Args {
229
+ export interface LaunchBootstrapArgs {
230
+ allowHome?: boolean;
231
+ cwd?: string;
232
+ noSandbox?: boolean;
233
+ allowPath: string[];
234
+ noMemories?: boolean;
235
+ hooks: string[];
236
+ extensions: string[];
237
+ noExtensions?: boolean;
238
+ pluginDirs: string[];
239
+ preExtensionExit: boolean;
240
+ }
241
+
242
+ const BOOTSTRAP_VALUE_FLAGS = new Set(["allow-path", "hook", "extension", "plugin-dir"]);
243
+ const PRE_EXTENSION_EXITS = new Set(["version", "list-models", "export"]);
244
+
245
+ /**
246
+ * Read only the built-in controls needed to load extensions.
247
+ *
248
+ * This is deliberately not an argument parse: it never classifies positional input, files, or
249
+ * extension flags. It only follows the known built-in grammar far enough to avoid mistaking a
250
+ * built-in value for a discovery control. The authoritative parse happens after this scan.
251
+ */
252
+ export function scanLaunchBootstrapArgs(args: readonly string[]): LaunchBootstrapArgs {
253
+ const result: LaunchBootstrapArgs = {
254
+ allowPath: [],
255
+ hooks: [],
256
+ extensions: [],
257
+ pluginDirs: [],
258
+ preExtensionExit: false,
259
+ };
260
+ const tokens = normalizeFlagTokens(args);
261
+
262
+ for (let i = 0; i < tokens.length; i++) {
263
+ const token = tokens[i];
264
+ if (token === "--") break;
265
+ if (!token.startsWith("-") || token === "-") continue;
266
+
267
+ const name = token.startsWith("--") ? token.slice(2) : flagNameForChar(token.slice(1));
268
+ if (name === undefined) continue;
269
+ const spec = flagSpec(name);
270
+ if (!spec) continue;
271
+
272
+ let value: string | true = true;
273
+ if (spec.arity === "optional-value") {
274
+ if (isValueToken(tokens[i + 1])) value = tokens[++i];
275
+ } else if (takesValue(spec)) {
276
+ if (tokens[i + 1] === undefined) continue;
277
+ value = tokens[++i];
278
+ }
279
+
280
+ if (PRE_EXTENSION_EXITS.has(name)) result.preExtensionExit = true;
281
+ if (name === "allow-home") result.allowHome = true;
282
+ if (name === "no-sandbox") result.noSandbox = true;
283
+ if (name === "no-memories") result.noMemories = true;
284
+ if (name === "no-extensions") result.noExtensions = true;
285
+ if (BOOTSTRAP_VALUE_FLAGS.has(name) && value !== true) {
286
+ if (name === "allow-path") result.allowPath.push(value);
287
+ if (name === "hook") result.hooks.push(value);
288
+ if (name === "extension") result.extensions.push(value);
289
+ if (name === "plugin-dir") result.pluginDirs.push(value);
290
+ }
291
+ }
292
+
293
+ return result;
294
+ }
295
+
296
+ export interface ResolvedLaunchArgs {
297
+ bootstrap: LaunchBootstrapArgs;
298
+ parsed: Args;
299
+ extensionFlags?: ExtensionFlagRegistry;
300
+ }
301
+
302
+ /**
303
+ * Resolve extension registrations before the one parse whose result drives launch behavior.
304
+ * Version/model-list/export retain their pre-extension fast path.
305
+ */
306
+ export async function resolveLaunchArgs(
307
+ args: readonly string[],
308
+ loadExtensionFlags: (bootstrap: LaunchBootstrapArgs) => Promise<ExtensionFlagRegistry>,
309
+ ): Promise<ResolvedLaunchArgs> {
310
+ const bootstrap = scanLaunchBootstrapArgs(args);
311
+ const extensionFlags = bootstrap.preExtensionExit ? undefined : await loadExtensionFlags(bootstrap);
312
+ return {
313
+ bootstrap,
314
+ parsed: parseArgs([...args], extensionFlags),
315
+ ...(extensionFlags ? { extensionFlags } : {}),
316
+ };
317
+ }
318
+
319
+ export function parseArgs(args: string[], extensionFlags?: ExtensionFlagRegistry): Args {
228
320
  const result: Args = {
229
321
  messages: [],
230
322
  fileArgs: [],
@@ -282,23 +374,20 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
282
374
  continue;
283
375
  }
284
376
 
285
- // Extension flags are only known on the second parse, once extensions have loaded.
377
+ // Extension registrations are loaded before this authoritative parse.
286
378
  const extFlag = name === undefined ? undefined : extensionFlags?.get(name);
287
379
  if (extFlag && name !== undefined) {
288
380
  if (extFlag.type === "boolean") {
289
- result.unknownFlags.set(name, true);
381
+ const inlineValue = token.startsWith("--") ? token.split("=", 2)[1] : undefined;
382
+ result.unknownFlags.set(name, inlineValue === undefined ? true : inlineValue === "true");
290
383
  } else if (i + 1 < tokens.length) {
291
384
  result.unknownFlags.set(name, tokens[++i]);
292
385
  }
293
386
  continue;
294
387
  }
295
388
 
296
- // Record the flag, but do NOT consume the token after it. The bootstrap parse runs before
297
- // extensions load, so it cannot know whether an unrecognized flag takes a value: swallowing
298
- // the next token silently discards the user's prompt whenever the flag turns out to be
299
- // boolean (`xcsh -p --verbose "do work"`). Leaving it means a string extension flag's value
300
- // still reaches `messages`, which is the pre-existing behaviour and the lesser harm — the
301
- // real fix is to load extensions before the first parse, which is out of scope here.
389
+ // Do not consume a following token for a genuine unknown flag: it may be prompt content.
390
+ // Registered extension flags never reach this path because launch discovers them first.
302
391
  result.unrecognizedFlags.push({ token, name: name ?? token.replace(/^-+/, "") });
303
392
  }
304
393
 
@@ -175,20 +175,23 @@ export function validateInlineFlagSyntax(args: readonly string[], extensionFlags
175
175
  if (!inline) continue;
176
176
 
177
177
  const spec = flagSpec(inline.name);
178
- const isBoolean = spec?.arity === "boolean" || extensionFlags?.get(inline.name)?.type === "boolean";
179
- if (isBoolean) {
178
+ if (spec?.arity === "boolean") {
180
179
  throw new CliUsageError(`--${inline.name} is a boolean flag and does not take a value`);
181
180
  }
181
+ if (extensionFlags?.get(inline.name)?.type === "boolean" && inline.value !== "true" && inline.value !== "false") {
182
+ throw new CliUsageError(`--${inline.name} is a boolean flag and expects true or false`);
183
+ }
182
184
  }
183
185
  }
184
186
 
185
187
  /**
186
188
  * Rewrite `--name=value` into `["--name", "value"]` for every flag that takes a value.
187
189
  *
188
- * A boolean flag with `=` is an error rather than a guess: accepting `--no-sandbox=true` invites
190
+ * A built-in boolean flag with `=` is an error rather than a guess: accepting `--no-sandbox=true` invites
189
191
  * `--no-sandbox=false`, which the parser has no way to express, and quietly reading it as "on" would
190
192
  * be exactly the class of bug #2469 reports. Unknown names are left intact so the unknown-flag path
191
193
  * can report the token as the user wrote it.
194
+ * Extension boolean flags accept explicit true or false values through their runtime map.
192
195
  *
193
196
  * Short forms are untouched: no shell convention makes `-p=x` mean `-p x`.
194
197
  */
@@ -223,7 +226,11 @@ export function normalizeFlagTokens(args: readonly string[], extensionFlags?: Ex
223
226
 
224
227
  const extension = extensionFlags?.get(name);
225
228
  if (extension) {
226
- normalized.push(`--${name}`, value);
229
+ if (extension.type === "boolean") {
230
+ normalized.push(arg);
231
+ } else {
232
+ normalized.push(`--${name}`, value);
233
+ }
227
234
  continue;
228
235
  }
229
236
 
@@ -425,6 +425,17 @@ async function handleInstall(
425
425
  }
426
426
 
427
427
  if (target.type === "marketplace") {
428
+ if (flags.dryRun) {
429
+ const preview = {
430
+ action: "install",
431
+ target: `${target.name}@${target.marketplace}`,
432
+ scope: flags.scope ?? "user",
433
+ dryRun: true,
434
+ };
435
+ if (flags.json) console.log(JSON.stringify(preview, null, 2));
436
+ else console.log(chalk.dim(`[dry-run] Would install ${preview.target} (${preview.scope})`));
437
+ continue;
438
+ }
428
439
  try {
429
440
  const entry = await mktMgr.installPlugin(target.name, target.marketplace, {
430
441
  force: flags.force,
@@ -6,10 +6,17 @@ import { executeShell, fencePermits } from "@f5-sales-demo/pi-natives";
6
6
  import { isEnoent } from "@f5-sales-demo/pi-utils";
7
7
  import { Settings } from "../config/settings";
8
8
  import { fenceForNative } from "../exec/bash-executor";
9
- import { buildContainmentFence, type ContainmentFence, containmentStatus, fenceVerdict } from "../sandbox/containment";
9
+ import {
10
+ buildContainmentFence,
11
+ type ContainmentFence,
12
+ containmentStatus,
13
+ fenceVerdict,
14
+ seatbeltFenceVerdict,
15
+ } from "../sandbox/containment";
10
16
  import { evaluateToolCall } from "../sandbox/enforce";
11
17
  import {
12
18
  SANDBOX_CHECK_NAMED_SIBLING_ENV,
19
+ SANDBOX_CHECK_NAMED_SIBLING_EXPECTATION_ENV,
13
20
  SANDBOX_OPERATOR_HOME_ENV,
14
21
  SANDBOX_SESSION_ROOT_ENV,
15
22
  sandboxCheckSiblingRoot,
@@ -74,6 +81,13 @@ function sanitizeDetail(value: string, redactions: readonly Redaction[]): string
74
81
  return sanitized.length > 500 ? `${sanitized.slice(0, 497)}...` : sanitized;
75
82
  }
76
83
 
84
+ function inheritedNamedSiblingDenied(value: string | undefined): boolean | undefined {
85
+ if (value === undefined) return undefined;
86
+ if (value === "denied") return true;
87
+ if (value === "allowed") return false;
88
+ throw new Error(`invalid ${SANDBOX_CHECK_NAMED_SIBLING_EXPECTATION_ENV}: expected "allowed" or "denied"`);
89
+ }
90
+
77
91
  function errnoFromOutput(output: string): string {
78
92
  if (/operation not permitted/iu.test(output)) return "EPERM";
79
93
  if (/permission denied/iu.test(output)) return "EACCES";
@@ -210,6 +224,10 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
210
224
  const inheritedHome = process.env[SANDBOX_OPERATOR_HOME_ENV];
211
225
  const inheritedSibling = process.env[SANDBOX_CHECK_NAMED_SIBLING_ENV];
212
226
  const inheritedProfile = inheritedWorkspace !== undefined;
227
+ const inheritedSiblingDenied =
228
+ inheritedProfile && inheritedSibling !== undefined
229
+ ? inheritedNamedSiblingDenied(process.env[SANDBOX_CHECK_NAMED_SIBLING_EXPECTATION_ENV])
230
+ : undefined;
213
231
  const workspaceInput = inheritedWorkspace ?? process.cwd();
214
232
  const homeInput = inheritedHome ?? os.homedir();
215
233
  redactions.push([workspaceInput, "<workspace>"], [homeInput, "<operator-home>"]);
@@ -462,7 +480,7 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
462
480
  );
463
481
  });
464
482
 
465
- await check("named sibling remains reachable", async () => {
483
+ await check("named sibling follows the active boundary", async () => {
466
484
  const displayPath = "<session-parent>/<synthetic-sibling>";
467
485
  let liveSibling = inheritedSibling;
468
486
  if (liveSibling === undefined) {
@@ -480,13 +498,24 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
480
498
  return exceptionOutcome("create named sibling fixture", displayPath, error, redactions);
481
499
  }
482
500
  }
501
+ const seatbeltDeniesSibling =
502
+ inheritedSiblingDenied ??
503
+ (backend.backend === "seatbelt" && seatbeltFenceVerdict(liveFence, liveSibling, "read") === "deny");
483
504
  const result = await shellProbe(
484
- 'test "$(cat named.txt)" = sibling',
485
- liveSibling,
486
- undefined,
505
+ `cd ${quote(liveSibling)} && test "$(cat named.txt)" = sibling`,
506
+ liveWorkspace,
507
+ inheritedProfile ? undefined : liveFence,
487
508
  abortController.signal,
488
509
  );
489
- return shellOutcome(result, true, "live profile must allow a named sibling read", displayPath, redactions);
510
+ return shellOutcome(
511
+ result,
512
+ !seatbeltDeniesSibling,
513
+ seatbeltDeniesSibling
514
+ ? "Seatbelt must deny a named sibling outside the workspace"
515
+ : "live profile must allow a named sibling read",
516
+ displayPath,
517
+ redactions,
518
+ );
490
519
  });
491
520
 
492
521
  if (backend.osEnforced) {
@@ -4,7 +4,6 @@
4
4
 
5
5
  import { APP_NAME } from "@f5-sales-demo/pi-utils";
6
6
  import { Args, Command } from "@f5-sales-demo/pi-utils/cli";
7
- import { parseArgs } from "../cli/args";
8
7
  import { buildCliFlags } from "../cli/flag-spec";
9
8
  import { runRootCommand } from "../main";
10
9
 
@@ -36,7 +35,6 @@ export default class Index extends Command {
36
35
  static strict = false;
37
36
 
38
37
  async run(): Promise<void> {
39
- const parsed = parseArgs(this.argv);
40
- await runRootCommand(parsed, this.argv);
38
+ await runRootCommand(this.argv);
41
39
  }
42
40
  }
@@ -187,6 +187,8 @@ const ThinkingControlModeSchema = Type.Union([
187
187
  const ModelThinkingSchema = Type.Object({
188
188
  minLevel: EffortSchema,
189
189
  maxLevel: EffortSchema,
190
+ defaultLevel: Type.Optional(EffortSchema),
191
+ canDisable: Type.Optional(Type.Boolean()),
190
192
  mode: ThinkingControlModeSchema,
191
193
  });
192
194
 
@@ -843,12 +843,13 @@ export async function listXcshPluginRoots(
843
843
  roots.push(...projectRoots, ...deduped);
844
844
  }
845
845
 
846
- // Merge --plugin-dir roots (highest precedence) on every fresh load
846
+ // Merge --plugin-dir roots (highest precedence) on every fresh load. Local roots use
847
+ // an artificial marketplace ID, so identity must be matched by manifest plugin name;
848
+ // comparing full IDs would leave the installed copy active beside the candidate.
847
849
  if (injectedPluginDirRoots.length > 0) {
848
- const injectedIds = new Set(injectedPluginDirRoots.map(r => r.id));
849
- const filtered = roots.filter(r => !injectedIds.has(r.id));
850
+ const merged = prioritizeInjectedPluginRoots(roots, injectedPluginDirRoots);
850
851
  roots.length = 0;
851
- roots.push(...injectedPluginDirRoots, ...filtered);
852
+ roots.push(...merged);
852
853
  }
853
854
 
854
855
  const result = { roots, warnings };
@@ -856,6 +857,19 @@ export async function listXcshPluginRoots(
856
857
  return result;
857
858
  }
858
859
 
860
+ export function prioritizeInjectedPluginRoots(
861
+ installed: XcshPluginRoot[],
862
+ injected: XcshPluginRoot[],
863
+ ): XcshPluginRoot[] {
864
+ const seen = new Set<string>();
865
+ const winners = injected.filter(root => {
866
+ if (seen.has(root.plugin)) return false;
867
+ seen.add(root.plugin);
868
+ return true;
869
+ });
870
+ return [...winners, ...installed.filter(root => !seen.has(root.plugin))];
871
+ }
872
+
859
873
  export interface XcshPluginSummary {
860
874
  /** Registry id (root.plugin) — the key the `xcsh://plugin/<id>` resolver matches on. */
861
875
  id: string;
@@ -24,6 +24,7 @@ export function fenceForNative(fence: ContainmentFence | undefined) {
24
24
  allowReadOnly: [...fence.allowReadOnly],
25
25
  allowWriteOnly: [...fence.allowWriteOnly],
26
26
  deny: [...fence.deny],
27
+ denyOnSeatbelt: [...fence.denyOnSeatbelt],
27
28
  denyEnumerate: [...fence.denyEnumerate],
28
29
  };
29
30
  }
@@ -171,7 +171,7 @@ function renderContainment(containment: ContainmentStatus | null): string {
171
171
  if (!containment) return "";
172
172
  // `landlock` is derived rather than another field on the status, because the template needs a
173
173
  // boolean and Handlebars cannot compare strings. It gates the Linux-only costs — unlistable split
174
- // directories, no setuid, no interactive terminal — which are true of that backend and no other.
174
+ // directories and no setuid — which are true of that backend and no other.
175
175
  return prompt.render(containmentTemplate, {
176
176
  containment: { ...containment, landlock: containment.backend === "landlock" },
177
177
  });
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "21.0.0",
21
- "commit": "7402d0fe1d43a25000908463ed254d54c71d0b6b",
22
- "shortCommit": "7402d0f",
20
+ "version": "21.1.0",
21
+ "commit": "5c6e84a195bcf81ba561bf85c5ecf7a47d8c62c1",
22
+ "shortCommit": "5c6e84a",
23
23
  "branch": "main",
24
- "tag": "v21.0.0",
25
- "commitDate": "2026-08-28T10:34:07+00:00",
26
- "buildDate": "2026-08-28T11:01:38.586Z",
24
+ "tag": "v21.1.0",
25
+ "commitDate": "2026-08-28T19:03:09+00:00",
26
+ "buildDate": "2026-08-28T19:42:43.325Z",
27
27
  "dirty": true,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5-sales-demo/xcsh",
30
30
  "repoSlug": "f5-sales-demo/xcsh",
31
- "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/7402d0fe1d43a25000908463ed254d54c71d0b6b",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v21.0.0"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/5c6e84a195bcf81ba561bf85c5ecf7a47d8c62c1",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v21.1.0"
33
33
  };
@@ -121,10 +121,10 @@ function renderResource(resource: string, catalog: ConsoleCatalogData, fieldMeta
121
121
  const doc = (parseYaml(raw) ?? {}) as Record<string, unknown>;
122
122
  const console_ = (doc.console ?? {}) as Record<string, unknown>;
123
123
  const lines = [`# ${(doc.label as string | undefined) ?? key}`, ""];
124
- if (console_.route_pattern) {
125
- const fullRoute = console_.route_prefix
126
- ? `${console_.route_prefix}${console_.route_pattern}`
127
- : console_.route_pattern;
124
+ const routePattern = typeof console_.route_pattern === "string" ? console_.route_pattern : undefined;
125
+ if (routePattern) {
126
+ const routePrefix = typeof console_.route_prefix === "string" ? console_.route_prefix : undefined;
127
+ const fullRoute = routePrefix ? `${routePrefix}${routePattern}` : routePattern;
128
128
  lines.push(`**Route:** \`${fullRoute}\``, "");
129
129
  }
130
130
  if (Array.isArray(console_.menu_path)) lines.push(`**Menu:** ${(console_.menu_path as string[]).join(" › ")}`, "");
@@ -155,8 +155,9 @@ function renderWorkflow(resource: string, operation: string, catalog: ConsoleCat
155
155
  for (const s of steps) {
156
156
  const sel = s.selector ? ` \`${s.selector}\`` : "";
157
157
  const val = s.value != null ? ` = ${JSON.stringify(s.value)}` : "";
158
+ const action = typeof s.action === "string" ? s.action : "unknown";
158
159
  lines.push(
159
- `1. **${s.action}**${sel}${val} — ${(s.description as string | undefined) ?? (s.id as string | undefined) ?? ""}`,
160
+ `1. **${action}**${sel}${val} — ${(s.description as string | undefined) ?? (s.id as string | undefined) ?? ""}`,
160
161
  );
161
162
  }
162
163
  return `${lines.join("\n")}\n`;
package/src/main.ts CHANGED
@@ -28,7 +28,7 @@ import { ChatHandler } from "./browser/chat-handler";
28
28
  import { type BridgeServer, startBridgeServer } from "./browser/extension-bridge";
29
29
  import { setSharedBridgeServer } from "./browser/provider";
30
30
  import { invalidate as invalidateFsCache } from "./capability/fs";
31
- import { type Args, parseArgs } from "./cli/args";
31
+ import { type Args, resolveLaunchArgs } from "./cli/args";
32
32
  import { processFileArguments } from "./cli/file-processor";
33
33
  import { LAUNCH_FLAGS } from "./cli/flag-spec";
34
34
  import { buildInitialMessage } from "./cli/initial-message";
@@ -46,7 +46,8 @@ import {
46
46
  resolveActiveProjectRegistryPath,
47
47
  } from "./discovery/helpers";
48
48
  import { exportFromFile } from "./export/html";
49
- import type { ExtensionUIContext } from "./extensibility/extensions/types";
49
+ import { discoverAndLoadExtensions, loadExtensions } from "./extensibility/extensions";
50
+ import type { ExtensionFlag, ExtensionUIContext, LoadExtensionsResult } from "./extensibility/extensions/types";
50
51
  import {
51
52
  getInstalledPluginsRegistryPath,
52
53
  getMarketplacesCacheDir,
@@ -66,7 +67,7 @@ import { resolveResumableSession, type SessionInfo, SessionManager } from "./ses
66
67
  import { profileDump, profileMark } from "./startup-profile";
67
68
  import { resolvePromptInput } from "./system-prompt";
68
69
  import type { LspStartupServerInfo } from "./tools";
69
- import type { EventBus } from "./utils/event-bus";
70
+ import { EventBus } from "./utils/event-bus";
70
71
  import { fuzzyFilter } from "./utils/fuzzy";
71
72
 
72
73
  async function checkForNewVersion(currentVersion: string): Promise<string | undefined> {
@@ -314,7 +315,7 @@ async function createSessionManager(parsed: Args, cwd: string): Promise<SessionM
314
315
  return undefined;
315
316
  }
316
317
 
317
- async function maybeAutoChdir(parsed: Args): Promise<void> {
318
+ async function maybeAutoChdir(parsed: Pick<Args, "allowHome" | "cwd">): Promise<void> {
318
319
  if (parsed.allowHome || parsed.cwd) {
319
320
  return;
320
321
  }
@@ -597,7 +598,23 @@ function reportUnrecognizedFlags(
597
598
  process.exit(1);
598
599
  }
599
600
 
600
- export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<void> {
601
+ function collectExtensionFlags(result: LoadExtensionsResult): {
602
+ flags: Map<string, ExtensionFlag>;
603
+ registeredNames: Map<string, string>;
604
+ } {
605
+ const flags = new Map<string, ExtensionFlag>();
606
+ const registeredNames = new Map<string, string>();
607
+ for (const extension of result.extensions) {
608
+ for (const [registeredName, flag] of extension.flags) {
609
+ const cliName = registeredName.replace(/^--/, "");
610
+ flags.set(cliName, flag);
611
+ registeredNames.set(cliName, registeredName);
612
+ }
613
+ }
614
+ return { flags, registeredNames };
615
+ }
616
+
617
+ export async function runRootCommand(rawArgs: string[]): Promise<void> {
601
618
  logger.startTiming();
602
619
  profileMark("entry: runtime + module-graph loaded (pre-main)");
603
620
 
@@ -605,8 +622,60 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
605
622
  // Will be re-initialized with user preferences later
606
623
  await logger.time("initTheme:initial", initTheme);
607
624
 
608
- const parsedArgs = parsed;
609
- await logger.time("maybeAutoChdir", maybeAutoChdir, parsedArgs);
625
+ let preloadedExtensions: LoadExtensionsResult | undefined;
626
+ let extensionEventBus: EventBus | undefined;
627
+ let registeredFlagNames = new Map<string, string>();
628
+ const resolved = await resolveLaunchArgs(rawArgs, async bootstrap => {
629
+ await logger.time("maybeAutoChdir", maybeAutoChdir, bootstrap);
630
+ const cwd = getProjectDir();
631
+ const sandboxOverrides: Partial<Record<SettingPath, unknown>> = {};
632
+ if (bootstrap.noSandbox) sandboxOverrides["sandbox.enabled"] = false;
633
+ if (bootstrap.noMemories) sandboxOverrides["memories.enabled"] = false;
634
+ if (bootstrap.allowPath.length > 0) {
635
+ sandboxOverrides["sandbox.allowRead"] = bootstrap.allowPath;
636
+ sandboxOverrides["sandbox.allowWrite"] = bootstrap.allowPath;
637
+ }
638
+ await logger.time("settings:init", Settings.init, {
639
+ cwd,
640
+ overrides: Object.keys(sandboxOverrides).length > 0 ? sandboxOverrides : undefined,
641
+ });
642
+ initializeWithSettings(settings);
643
+
644
+ const home = os.homedir();
645
+ if (bootstrap.pluginDirs.length > 0) {
646
+ await logger.time("injectPluginDirRoots", injectPluginDirRoots, home, bootstrap.pluginDirs, cwd);
647
+ } else {
648
+ await logger.time("preloadPluginRoots", preloadPluginRoots, home, cwd);
649
+ }
650
+
651
+ const configuredPaths = [...bootstrap.extensions, ...bootstrap.hooks];
652
+ extensionEventBus = new EventBus();
653
+ preloadedExtensions = bootstrap.noExtensions
654
+ ? await logger.time("loadExtensions", loadExtensions, configuredPaths, cwd, extensionEventBus)
655
+ : await logger.time(
656
+ "discoverAndLoadExtensions",
657
+ discoverAndLoadExtensions,
658
+ [...configuredPaths, ...(settings.get("extensions") ?? [])],
659
+ cwd,
660
+ extensionEventBus,
661
+ settings.get("disabledExtensions") ?? [],
662
+ );
663
+ for (const { path: extensionPath, error } of preloadedExtensions.errors) {
664
+ logger.error("Failed to load extension", { path: extensionPath, error });
665
+ }
666
+ const collected = collectExtensionFlags(preloadedExtensions);
667
+ registeredFlagNames = collected.registeredNames;
668
+ return collected.flags;
669
+ });
670
+ const parsedArgs = resolved.parsed;
671
+ if (resolved.bootstrap.preExtensionExit) {
672
+ await logger.time("maybeAutoChdir", maybeAutoChdir, parsedArgs);
673
+ } else {
674
+ reportUnrecognizedFlags(parsedArgs, resolved.extensionFlags);
675
+ for (const [flagName, value] of parsedArgs.unknownFlags) {
676
+ preloadedExtensions?.runtime.flagValues.set(registeredFlagNames.get(flagName) ?? flagName, value);
677
+ }
678
+ }
610
679
 
611
680
  const notifs: (InteractiveModeNotify | null)[] = [];
612
681
 
@@ -655,17 +724,6 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
655
724
  }
656
725
 
657
726
  const cwd = getProjectDir();
658
- const sandboxOverrides: Partial<Record<SettingPath, unknown>> = {};
659
- if (parsedArgs.noSandbox) sandboxOverrides["sandbox.enabled"] = false;
660
- if (parsedArgs.noMemories) sandboxOverrides["memories.enabled"] = false;
661
- if (parsedArgs.allowPath?.length) {
662
- sandboxOverrides["sandbox.allowRead"] = parsedArgs.allowPath;
663
- sandboxOverrides["sandbox.allowWrite"] = parsedArgs.allowPath;
664
- }
665
- await logger.time("settings:init", Settings.init, {
666
- cwd,
667
- overrides: Object.keys(sandboxOverrides).length > 0 ? sandboxOverrides : undefined,
668
- });
669
727
 
670
728
  // F5 XC context is session-scoped: nothing loads at startup. We still init the
671
729
  // ContextService singleton so /context commands and the session bootstrap work.
@@ -712,9 +770,6 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
712
770
  const isInteractive = !parsedArgs.print && !autoPrint && parsedArgs.mode === undefined;
713
771
  const mode = parsedArgs.mode || "text";
714
772
 
715
- // Initialize discovery system with settings for provider persistence
716
- logger.time("initializeWithSettings");
717
- initializeWithSettings(settings);
718
773
  modelRegistry.refreshInBackground();
719
774
 
720
775
  // Apply model role overrides from CLI args or env vars (ephemeral, not persisted)
@@ -807,14 +862,6 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
807
862
  })();
808
863
  }
809
864
 
810
- // Wire --plugin-dir and preload plugin roots for sync consumers (LSP config)
811
- const home = os.homedir();
812
- if (parsedArgs.pluginDirs && parsedArgs.pluginDirs.length > 0) {
813
- await logger.time("injectPluginDirRoots", injectPluginDirRoots, home, parsedArgs.pluginDirs!, getProjectDir());
814
- } else {
815
- await logger.time("preloadPluginRoots", preloadPluginRoots, home, getProjectDir());
816
- }
817
-
818
865
  // Background marketplace update notification (non-blocking).
819
866
  if (autoUpdate === "notify") {
820
867
  void (async () => {
@@ -853,6 +900,10 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
853
900
  sessionOptions.authStorage = authStorage;
854
901
  sessionOptions.modelRegistry = modelRegistry;
855
902
  sessionOptions.hasUI = isInteractive;
903
+ if (preloadedExtensions && extensionEventBus) {
904
+ sessionOptions.preloadedExtensions = preloadedExtensions;
905
+ sessionOptions.eventBus = extensionEventBus;
906
+ }
856
907
 
857
908
  // Handle CLI --api-key as runtime override (not persisted)
858
909
  if (parsedArgs.apiKey) {
@@ -980,18 +1031,6 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
980
1031
  notifs.push({ kind: "error", message: modelRegistryError.message });
981
1032
  }
982
1033
 
983
- // Re-parse CLI args now that extension flags are known, and apply their values. The bootstrap
984
- // parse recorded each unclaimed flag with any value it consumed, so this only has to hand those
985
- // values over — and whatever no extension claims is a genuine unknown flag.
986
- {
987
- const extFlags = session.extensionRunner?.getFlags();
988
- const claimed = parseArgs(rawArgs, extFlags).unknownFlags;
989
- for (const [flagName, value] of claimed) {
990
- session.extensionRunner?.setFlagValue(flagName, value);
991
- }
992
- reportUnrecognizedFlags(parsedArgs, extFlags);
993
- }
994
-
995
1034
  if (!isInteractive && !session.model) {
996
1035
  if (modelFallbackMessage) {
997
1036
  process.stderr.write(`${chalk.red(modelFallbackMessage)}\n`);
@@ -1008,8 +1047,11 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
1008
1047
  const createAcpSession = async (cwd: string) => {
1009
1048
  const nextSettings = await session.settings.cloneForCwd(cwd);
1010
1049
  const nextSessionManager = SessionManager.create(cwd, parsedArgs.sessionDir);
1050
+ const nextSessionOptions = { ...sessionOptions };
1051
+ delete nextSessionOptions.preloadedExtensions;
1052
+ delete nextSessionOptions.eventBus;
1011
1053
  const { session: nextSession } = await createAgentSession({
1012
- ...sessionOptions,
1054
+ ...nextSessionOptions,
1013
1055
  cwd,
1014
1056
  sessionManager: nextSessionManager,
1015
1057
  settings: nextSettings,
@@ -674,7 +674,7 @@ export class AcpAgent implements Agent {
674
674
 
675
675
  #buildThinkingOptions(session: AgentSession): Array<{ value: string; name: string; description?: string }> {
676
676
  return [
677
- { value: THINKING_OFF, name: "Off" },
677
+ ...(session.model?.thinking?.canDisable === false ? [] : [{ value: THINKING_OFF, name: "Off" }]),
678
678
  ...session.getAvailableThinkingLevels().map(level => ({
679
679
  value: level,
680
680
  name: level,