@cmetech/otto 1.0.7 → 1.0.9

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 (53) hide show
  1. package/dist/cli-args.d.ts +2 -0
  2. package/dist/cli-args.js +6 -0
  3. package/dist/cli.js +27 -0
  4. package/dist/help-text.js +15 -0
  5. package/dist/onboarding.d.ts +19 -0
  6. package/dist/onboarding.js +133 -1
  7. package/dist/resources/.managed-resources-content-hash +1 -1
  8. package/dist/resources/extensions/otto/commands/release-notes/_data.js +155 -0
  9. package/dist/resources/extensions/otto/commands/release-notes/command.js +114 -0
  10. package/dist/resources/extensions/otto/extension-manifest.json +1 -1
  11. package/dist/resources/extensions/otto/index.js +3 -0
  12. package/dist/seed-defaults.d.ts +50 -0
  13. package/dist/seed-defaults.js +226 -0
  14. package/package.json +8 -7
  15. package/packages/contracts/package.json +1 -1
  16. package/packages/daemon/package.json +3 -3
  17. package/packages/mcp-server/package.json +3 -3
  18. package/packages/native/package.json +1 -1
  19. package/packages/pi-agent-core/package.json +1 -1
  20. package/packages/pi-ai/package.json +1 -1
  21. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  22. package/packages/pi-coding-agent/dist/core/agent-session.js +3 -0
  23. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  24. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +17 -0
  25. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  26. package/packages/pi-coding-agent/dist/core/extensions/runner.js +89 -1
  27. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  28. package/packages/pi-coding-agent/dist/core/package-manager.d.ts +9 -0
  29. package/packages/pi-coding-agent/dist/core/package-manager.d.ts.map +1 -1
  30. package/packages/pi-coding-agent/dist/core/package-manager.js +105 -67
  31. package/packages/pi-coding-agent/dist/core/package-manager.js.map +1 -1
  32. package/packages/pi-coding-agent/dist/core/resolve-config-value.d.ts.map +1 -1
  33. package/packages/pi-coding-agent/dist/core/resolve-config-value.js +20 -2
  34. package/packages/pi-coding-agent/dist/core/resolve-config-value.js.map +1 -1
  35. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +25 -0
  36. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  37. package/packages/pi-coding-agent/dist/core/settings-manager.js +41 -0
  38. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  39. package/packages/pi-coding-agent/package.json +2 -2
  40. package/packages/pi-coding-agent/src/core/agent-session.ts +3 -0
  41. package/packages/pi-coding-agent/src/core/extensions/runner.ts +90 -1
  42. package/packages/pi-coding-agent/src/core/package-manager.ts +131 -64
  43. package/packages/pi-coding-agent/src/core/resolve-config-value.ts +20 -2
  44. package/packages/pi-coding-agent/src/core/settings-manager.ts +56 -0
  45. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  46. package/packages/pi-tui/package.json +1 -1
  47. package/packages/rpc-client/package.json +2 -2
  48. package/pkg/package.json +1 -1
  49. package/scripts/install.js +5 -2
  50. package/src/resources/extensions/otto/commands/release-notes/_data.ts +171 -0
  51. package/src/resources/extensions/otto/commands/release-notes/command.ts +141 -0
  52. package/src/resources/extensions/otto/extension-manifest.json +1 -1
  53. package/src/resources/extensions/otto/index.ts +4 -0
@@ -2,6 +2,7 @@
2
2
  * Extension runner - executes extensions and manages their lifecycle.
3
3
  */
4
4
 
5
+ import { AsyncLocalStorage } from "node:async_hooks";
5
6
  import type { AgentMessage } from "@otto/pi-agent-core";
6
7
  import type { ImageContent, Model } from "@otto/pi-ai";
7
8
  import type { KeyId } from "@otto/pi-tui";
@@ -222,6 +223,49 @@ const noOpUIContext: ExtensionUIContext = {
222
223
  setToolsExpanded: () => {},
223
224
  };
224
225
 
226
+ // Async context flag: true when the current async stack originates inside a
227
+ // quiet extension's handler invocation. Read by the patched console methods to
228
+ // decide whether to suppress output. Empty store = normal logging behaviour.
229
+ const quietExtensionContext = new AsyncLocalStorage<boolean>();
230
+
231
+ let quietConsolePatchInstalled = false;
232
+
233
+ /**
234
+ * Patch `console.log/warn/error/info` to no-op when the current async context
235
+ * is flagged as "quiet". Installed once on first `setQuietExtensions(non-empty)`
236
+ * call so users who never configure quietExtensions pay zero overhead.
237
+ *
238
+ * The check is `quietExtensionContext.getStore() === true` — for any code path
239
+ * that wasn't dispatched inside `quietExtensionContext.run(true, …)` the store
240
+ * is undefined and console behaves exactly as before. This isolates the
241
+ * suppression to the quiet handler's own async work without affecting
242
+ * concurrent non-quiet handlers.
243
+ */
244
+ function installQuietConsolePatch(): void {
245
+ if (quietConsolePatchInstalled) return;
246
+ quietConsolePatchInstalled = true;
247
+ const realLog = console.log.bind(console);
248
+ const realWarn = console.warn.bind(console);
249
+ const realError = console.error.bind(console);
250
+ const realInfo = console.info.bind(console);
251
+ console.log = (...args: unknown[]): void => {
252
+ if (quietExtensionContext.getStore() === true) return;
253
+ realLog(...args);
254
+ };
255
+ console.warn = (...args: unknown[]): void => {
256
+ if (quietExtensionContext.getStore() === true) return;
257
+ realWarn(...args);
258
+ };
259
+ console.error = (...args: unknown[]): void => {
260
+ if (quietExtensionContext.getStore() === true) return;
261
+ realError(...args);
262
+ };
263
+ console.info = (...args: unknown[]): void => {
264
+ if (quietExtensionContext.getStore() === true) return;
265
+ realInfo(...args);
266
+ };
267
+ }
268
+
225
269
  export class ExtensionRunner {
226
270
  private extensions: Extension[];
227
271
  private runtime: ExtensionRuntime;
@@ -257,6 +301,8 @@ export class ExtensionRunner {
257
301
  private shutdownHandler: ShutdownHandler = () => {};
258
302
  private shortcutDiagnostics: ResourceDiagnostic[] = [];
259
303
  private commandDiagnostics: ResourceDiagnostic[] = [];
304
+ /** Case-insensitive substring patterns matched against ext.path. ui.notify is dropped for matches. */
305
+ private quietExtensionPatterns: string[] = [];
260
306
 
261
307
  constructor(
262
308
  extensions: Extension[],
@@ -734,6 +780,41 @@ export class ExtensionRunner {
734
780
  return eventType === "agent_end" || eventType === "stop" || eventType === "session_end";
735
781
  }
736
782
 
783
+ /**
784
+ * Replace the quiet-extensions allowlist. Each entry is a case-insensitive
785
+ * substring matched against the extension's filesystem path. Matching
786
+ * extensions have their `ui.notify` calls silently dropped AND their
787
+ * `console.log/warn/error/info` calls silently dropped while the handler
788
+ * is on the stack — useful for muting noisy session_start banners
789
+ * (piolium uses ui.notify; pi-notion uses console.log).
790
+ *
791
+ * Console filtering uses AsyncLocalStorage so other extensions running
792
+ * concurrently are unaffected — output is only suppressed when the call
793
+ * originates inside a quiet extension's handler async context.
794
+ */
795
+ setQuietExtensions(patterns: readonly string[]): void {
796
+ this.quietExtensionPatterns = patterns.map((p) => p.toLowerCase()).filter((p) => p.length > 0);
797
+ if (this.quietExtensionPatterns.length > 0) {
798
+ installQuietConsolePatch();
799
+ }
800
+ }
801
+
802
+ private isQuietExtension(ext: Extension): boolean {
803
+ if (this.quietExtensionPatterns.length === 0) return false;
804
+ const path = ext.path.toLowerCase();
805
+ return this.quietExtensionPatterns.some((p) => path.includes(p));
806
+ }
807
+
808
+ private withSilencedNotify(base: ExtensionContext): ExtensionContext {
809
+ // Wraps the shared ui context so notify() is a no-op while every other
810
+ // method (select/confirm/input/setStatus/setWidget/…) keeps working.
811
+ const silencedUi: ExtensionUIContext = {
812
+ ...base.ui,
813
+ notify: () => undefined,
814
+ };
815
+ return { ...base, ui: silencedUi };
816
+ }
817
+
737
818
  createCommandContext(): ExtensionCommandContext {
738
819
  return {
739
820
  ...this.createContext(),
@@ -782,10 +863,18 @@ export class ExtensionRunner {
782
863
  const handlers = ext.handlers.get(eventType);
783
864
  if (!handlers || handlers.length === 0) continue;
784
865
 
866
+ const isQuiet = this.isQuietExtension(ext);
867
+ const extCtx = isQuiet ? this.withSilencedNotify(ctx) : ctx;
868
+
785
869
  for (const handler of handlers) {
786
870
  try {
787
871
  const event = getEvent();
788
- const handlerResult = await handler(event, ctx);
872
+ // Run quiet extensions inside an AsyncLocalStorage scope so
873
+ // the patched console methods can suppress their output
874
+ // without affecting non-quiet extensions running concurrently.
875
+ const handlerResult = isQuiet
876
+ ? await quietExtensionContext.run(true, () => handler(event, extCtx))
877
+ : await handler(event, extCtx);
789
878
  const action = processResult(handlerResult, ext.path);
790
879
  if (action.done) return;
791
880
  } catch (err) {
@@ -807,12 +807,13 @@ export class DefaultPackageManager implements PackageManager {
807
807
  const parsed = this.parseSource(source);
808
808
  const scope: SourceScope = options?.local ? "project" : "user";
809
809
  await this.withProgress("install", source, `Installing ${source}...`, async () => {
810
+ // Explicit user-initiated install — stream npm/git progress to the terminal.
810
811
  if (parsed.type === "npm") {
811
- await this.installNpm(parsed, scope, false);
812
+ await this.installNpm(parsed, scope, false, true);
812
813
  return;
813
814
  }
814
815
  if (parsed.type === "git") {
815
- await this.installGit(parsed, scope);
816
+ await this.installGit(parsed, scope, true);
816
817
  return;
817
818
  }
818
819
  if (parsed.type === "local") {
@@ -831,7 +832,7 @@ export class DefaultPackageManager implements PackageManager {
831
832
  const scope: SourceScope = options?.local ? "project" : "user";
832
833
  await this.withProgress("remove", source, `Removing ${source}...`, async () => {
833
834
  if (parsed.type === "npm") {
834
- await this.uninstallNpm(parsed, scope);
835
+ await this.uninstallNpm(parsed, scope, true);
835
836
  return;
836
837
  }
837
838
  if (parsed.type === "git") {
@@ -869,15 +870,16 @@ export class DefaultPackageManager implements PackageManager {
869
870
  const parsed = this.parseSource(source);
870
871
  if (parsed.type === "npm") {
871
872
  if (parsed.pinned) return;
873
+ // Explicit `pi package update` — stream npm progress to terminal.
872
874
  await this.withProgress("update", source, `Updating ${source}...`, async () => {
873
- await this.installNpm(parsed, scope, false);
875
+ await this.installNpm(parsed, scope, false, true);
874
876
  });
875
877
  return;
876
878
  }
877
879
  if (parsed.type === "git") {
878
880
  if (parsed.pinned) return;
879
881
  await this.withProgress("update", source, `Updating ${source}...`, async () => {
880
- await this.updateGit(parsed, scope);
882
+ await this.updateGit(parsed, scope, true);
881
883
  });
882
884
  return;
883
885
  }
@@ -890,54 +892,75 @@ export class DefaultPackageManager implements PackageManager {
890
892
  ): Promise<void> {
891
893
  for (const { pkg, scope } of sources) {
892
894
  const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
893
- const filter = typeof pkg === "object" ? pkg : undefined;
894
- const parsed = this.parseSource(sourceStr);
895
- const metadata: PathMetadata = { source: sourceStr, scope, origin: "package" };
896
-
897
- if (parsed.type === "local") {
898
- const baseDir = this.getBaseDirForScope(scope);
899
- this.resolveLocalExtensionSource(parsed, accumulator, filter, metadata, baseDir);
900
- continue;
895
+ try {
896
+ await this.resolveOnePackageSource(pkg, scope, accumulator, onMissing);
897
+ } catch (error) {
898
+ // One bad source (typo, 404, network glitch, unreachable git host)
899
+ // must not abort startup or block resolution of the remaining
900
+ // packages. Warn loudly so the user knows, then continue.
901
+ const message = error instanceof Error ? error.message : String(error);
902
+ process.stderr.write(
903
+ `[pi] Skipping package "${sourceStr}" — ${message}. ` +
904
+ `Run \`pi remove ${sourceStr}\` if you want to drop it permanently.\n`,
905
+ );
901
906
  }
907
+ }
908
+ }
902
909
 
903
- const installMissing = async (): Promise<boolean> => {
904
- if (isOfflineModeEnabled()) {
905
- return false;
906
- }
907
- if (!onMissing) {
908
- await this.installParsedSource(parsed, scope);
909
- return true;
910
- }
911
- const action = await onMissing(sourceStr);
912
- if (action === "skip") return false;
913
- if (action === "error") throw new Error(`Missing source: ${sourceStr}`);
910
+ private async resolveOnePackageSource(
911
+ pkg: PackageSource,
912
+ scope: SourceScope,
913
+ accumulator: ResourceAccumulator,
914
+ onMissing?: (source: string) => Promise<MissingSourceAction>,
915
+ ): Promise<void> {
916
+ const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
917
+ const filter = typeof pkg === "object" ? pkg : undefined;
918
+ const parsed = this.parseSource(sourceStr);
919
+ const metadata: PathMetadata = { source: sourceStr, scope, origin: "package" };
920
+
921
+ if (parsed.type === "local") {
922
+ const baseDir = this.getBaseDirForScope(scope);
923
+ this.resolveLocalExtensionSource(parsed, accumulator, filter, metadata, baseDir);
924
+ return;
925
+ }
926
+
927
+ const installMissing = async (): Promise<boolean> => {
928
+ if (isOfflineModeEnabled()) {
929
+ return false;
930
+ }
931
+ if (!onMissing) {
914
932
  await this.installParsedSource(parsed, scope);
915
933
  return true;
916
- };
934
+ }
935
+ const action = await onMissing(sourceStr);
936
+ if (action === "skip") return false;
937
+ if (action === "error") throw new Error(`Missing source: ${sourceStr}`);
938
+ await this.installParsedSource(parsed, scope);
939
+ return true;
940
+ };
917
941
 
918
- if (parsed.type === "npm") {
919
- const installedPath = this.getNpmInstallPath(parsed, scope);
920
- const needsInstall = !existsSync(installedPath) || (await this.npmNeedsUpdate(parsed, installedPath));
921
- if (needsInstall) {
922
- const installed = await installMissing();
923
- if (!installed) continue;
924
- }
925
- metadata.baseDir = installedPath;
926
- this.collectPackageResources(installedPath, accumulator, filter, metadata);
927
- continue;
942
+ if (parsed.type === "npm") {
943
+ const installedPath = this.getNpmInstallPath(parsed, scope);
944
+ const needsInstall = !existsSync(installedPath) || (await this.npmNeedsUpdate(parsed, installedPath));
945
+ if (needsInstall) {
946
+ const installed = await installMissing();
947
+ if (!installed) return;
928
948
  }
949
+ metadata.baseDir = installedPath;
950
+ this.collectPackageResources(installedPath, accumulator, filter, metadata);
951
+ return;
952
+ }
929
953
 
930
- if (parsed.type === "git") {
931
- const installedPath = this.getGitInstallPath(parsed, scope);
932
- if (!existsSync(installedPath)) {
933
- const installed = await installMissing();
934
- if (!installed) continue;
935
- } else if (scope === "temporary" && !parsed.pinned && !isOfflineModeEnabled()) {
936
- await this.refreshTemporaryGitSource(parsed, sourceStr);
937
- }
938
- metadata.baseDir = installedPath;
939
- this.collectPackageResources(installedPath, accumulator, filter, metadata);
954
+ if (parsed.type === "git") {
955
+ const installedPath = this.getGitInstallPath(parsed, scope);
956
+ if (!existsSync(installedPath)) {
957
+ const installed = await installMissing();
958
+ if (!installed) return;
959
+ } else if (scope === "temporary" && !parsed.pinned && !isOfflineModeEnabled()) {
960
+ await this.refreshTemporaryGitSource(parsed, sourceStr);
940
961
  }
962
+ metadata.baseDir = installedPath;
963
+ this.collectPackageResources(installedPath, accumulator, filter, metadata);
941
964
  }
942
965
  }
943
966
 
@@ -1169,21 +1192,34 @@ export class DefaultPackageManager implements PackageManager {
1169
1192
  return { name, version };
1170
1193
  }
1171
1194
 
1172
- private async installNpm(source: NpmSource, scope: SourceScope, temporary: boolean): Promise<void> {
1195
+ private async installNpm(
1196
+ source: NpmSource,
1197
+ scope: SourceScope,
1198
+ temporary: boolean,
1199
+ interactive: boolean = false,
1200
+ ): Promise<void> {
1173
1201
  const installRoot = this.getNpmInstallRoot(scope, temporary);
1174
1202
  this.ensureNpmProject(installRoot);
1175
- await this.runCommand("npm", ["install", source.spec, "--prefix", installRoot]);
1203
+ await this.runCommand("npm", ["install", source.spec, "--prefix", installRoot], { interactive });
1176
1204
  }
1177
1205
 
1178
- private async uninstallNpm(source: NpmSource, scope: SourceScope): Promise<void> {
1206
+ private async uninstallNpm(
1207
+ source: NpmSource,
1208
+ scope: SourceScope,
1209
+ interactive: boolean = false,
1210
+ ): Promise<void> {
1179
1211
  const installRoot = this.getNpmInstallRoot(scope, false);
1180
1212
  if (!existsSync(installRoot)) {
1181
1213
  return;
1182
1214
  }
1183
- await this.runCommand("npm", ["uninstall", source.name, "--prefix", installRoot]);
1215
+ await this.runCommand("npm", ["uninstall", source.name, "--prefix", installRoot], { interactive });
1184
1216
  }
1185
1217
 
1186
- private async installGit(source: GitSource, scope: SourceScope): Promise<void> {
1218
+ private async installGit(
1219
+ source: GitSource,
1220
+ scope: SourceScope,
1221
+ interactive: boolean = false,
1222
+ ): Promise<void> {
1187
1223
  const targetDir = this.getGitInstallPath(source, scope);
1188
1224
  if (existsSync(targetDir)) {
1189
1225
  return;
@@ -1194,40 +1230,44 @@ export class DefaultPackageManager implements PackageManager {
1194
1230
  }
1195
1231
  mkdirSync(dirname(targetDir), { recursive: true });
1196
1232
 
1197
- await this.runCommand("git", ["clone", source.repo, targetDir]);
1233
+ await this.runCommand("git", ["clone", source.repo, targetDir], { interactive });
1198
1234
  if (source.ref) {
1199
- await this.runCommand("git", ["checkout", source.ref], { cwd: targetDir });
1235
+ await this.runCommand("git", ["checkout", source.ref], { cwd: targetDir, interactive });
1200
1236
  }
1201
1237
  const packageJsonPath = join(targetDir, "package.json");
1202
1238
  if (existsSync(packageJsonPath)) {
1203
- await this.runCommand("npm", ["install"], { cwd: targetDir });
1239
+ await this.runCommand("npm", ["install"], { cwd: targetDir, interactive });
1204
1240
  }
1205
1241
  }
1206
1242
 
1207
- private async updateGit(source: GitSource, scope: SourceScope): Promise<void> {
1243
+ private async updateGit(
1244
+ source: GitSource,
1245
+ scope: SourceScope,
1246
+ interactive: boolean = false,
1247
+ ): Promise<void> {
1208
1248
  const targetDir = this.getGitInstallPath(source, scope);
1209
1249
  if (!existsSync(targetDir)) {
1210
- await this.installGit(source, scope);
1250
+ await this.installGit(source, scope, interactive);
1211
1251
  return;
1212
1252
  }
1213
1253
 
1214
1254
  // Fetch latest from remote (handles force-push by getting new history)
1215
- await this.runCommand("git", ["fetch", "--prune", "origin"], { cwd: targetDir });
1255
+ await this.runCommand("git", ["fetch", "--prune", "origin"], { cwd: targetDir, interactive });
1216
1256
 
1217
1257
  // Reset to tracking branch. Fall back to origin/HEAD when no upstream is configured.
1218
1258
  try {
1219
- await this.runCommand("git", ["reset", "--hard", "@{upstream}"], { cwd: targetDir });
1259
+ await this.runCommand("git", ["reset", "--hard", "@{upstream}"], { cwd: targetDir, interactive });
1220
1260
  } catch {
1221
- await this.runCommand("git", ["remote", "set-head", "origin", "-a"], { cwd: targetDir }).catch(() => {});
1222
- await this.runCommand("git", ["reset", "--hard", "origin/HEAD"], { cwd: targetDir });
1261
+ await this.runCommand("git", ["remote", "set-head", "origin", "-a"], { cwd: targetDir, interactive }).catch(() => {});
1262
+ await this.runCommand("git", ["reset", "--hard", "origin/HEAD"], { cwd: targetDir, interactive });
1223
1263
  }
1224
1264
 
1225
1265
  // Clean untracked files (extensions should be pristine)
1226
- await this.runCommand("git", ["clean", "-fdx"], { cwd: targetDir });
1266
+ await this.runCommand("git", ["clean", "-fdx"], { cwd: targetDir, interactive });
1227
1267
 
1228
1268
  const packageJsonPath = join(targetDir, "package.json");
1229
1269
  if (existsSync(packageJsonPath)) {
1230
- await this.runCommand("npm", ["install"], { cwd: targetDir });
1270
+ await this.runCommand("npm", ["install"], { cwd: targetDir, interactive });
1231
1271
  }
1232
1272
  }
1233
1273
 
@@ -1819,19 +1859,46 @@ export class DefaultPackageManager implements PackageManager {
1819
1859
  };
1820
1860
  }
1821
1861
 
1822
- private runCommand(command: string, args: string[], options?: { cwd?: string }): Promise<void> {
1862
+ /**
1863
+ * Run a child process. When `interactive` is true (the explicit user-facing
1864
+ * install/update path), stdio is inherited so npm/git progress shows in the
1865
+ * terminal. When false (the default — auto-resolve during launch), stdout
1866
+ * and stderr are captured. The captured stderr is appended to the failure
1867
+ * error so callers can surface the underlying cause without polluting the
1868
+ * TUI during startup.
1869
+ */
1870
+ private runCommand(
1871
+ command: string,
1872
+ args: string[],
1873
+ options?: { cwd?: string; interactive?: boolean },
1874
+ ): Promise<void> {
1875
+ const interactive = options?.interactive === true;
1823
1876
  return new Promise((resolvePromise, reject) => {
1824
1877
  const child = spawn(command, args, {
1825
1878
  cwd: options?.cwd,
1826
- stdio: "inherit",
1879
+ stdio: interactive ? "inherit" : ["ignore", "pipe", "pipe"],
1827
1880
  shell: process.platform === "win32",
1828
1881
  });
1882
+
1883
+ let stderr = "";
1884
+ if (!interactive) {
1885
+ child.stdout?.on("data", () => { /* drop — progress goes through withProgress */ });
1886
+ child.stderr?.on("data", (chunk: Buffer | string) => {
1887
+ stderr += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
1888
+ // Cap to avoid unbounded growth on chatty failures.
1889
+ if (stderr.length > 4_000) stderr = stderr.slice(-4_000);
1890
+ });
1891
+ }
1892
+
1829
1893
  child.on("error", reject);
1830
1894
  child.on("exit", (code) => {
1831
1895
  if (code === 0) {
1832
1896
  resolvePromise();
1833
1897
  } else {
1834
- reject(new Error(`${command} ${args.join(" ")} failed with code ${code}`));
1898
+ const detail = !interactive && stderr.trim().length > 0
1899
+ ? `: ${stderr.trim().split("\n").slice(-3).join(" ").slice(0, 500)}`
1900
+ : "";
1901
+ reject(new Error(`${command} ${args.join(" ")} failed with code ${code}${detail}`));
1835
1902
  }
1836
1903
  });
1837
1904
  });
@@ -24,6 +24,20 @@ export const SAFE_COMMAND_PREFIXES = [
24
24
  "lpass",
25
25
  ];
26
26
 
27
+ /**
28
+ * Whether to emit the per-blocked-command stderr warning. Off by default —
29
+ * extensions (e.g. piolium's anthropic-vertex registration) intentionally
30
+ * register !sh-style apiKey expressions that will never resolve on most user
31
+ * machines; the warning is noise. Opt back in by setting OTTO_LOG_BLOCKED_COMMANDS=1
32
+ * (or any truthy value) when actively debugging credential resolution.
33
+ */
34
+ function shouldLogBlockedCommands(): boolean {
35
+ const v = process.env.OTTO_LOG_BLOCKED_COMMANDS;
36
+ if (!v) return false;
37
+ const lv = v.toLowerCase();
38
+ return lv === "1" || lv === "true" || lv === "yes";
39
+ }
40
+
27
41
  /**
28
42
  * Active command prefix allowlist. Defaults to SAFE_COMMAND_PREFIXES but can be
29
43
  * overridden via setAllowedCommandPrefixes() (called from settings or env var).
@@ -70,13 +84,17 @@ function executeCommand(commandConfig: string): string | undefined {
70
84
  const tokens = command.split(/\s+/).filter(Boolean);
71
85
  const firstToken = tokens[0];
72
86
  if (!activeCommandPrefixes.includes(firstToken)) {
73
- process.stderr.write(`[resolve-config-value] Blocked disallowed command: "${firstToken}". Allowed: ${activeCommandPrefixes.join(", ")}\n`);
87
+ if (shouldLogBlockedCommands()) {
88
+ process.stderr.write(`[resolve-config-value] Blocked disallowed command: "${firstToken}". Allowed: ${activeCommandPrefixes.join(", ")}\n`);
89
+ }
74
90
  commandResultCache.set(commandConfig, undefined);
75
91
  return undefined;
76
92
  }
77
93
 
78
94
  if (SHELL_OPERATORS.test(command)) {
79
- process.stderr.write(`[resolve-config-value] Blocked shell operators in command: "${command}"\n`);
95
+ if (shouldLogBlockedCommands()) {
96
+ process.stderr.write(`[resolve-config-value] Blocked shell operators in command: "${command}"\n`);
97
+ }
80
98
  commandResultCache.set(commandConfig, undefined);
81
99
  return undefined;
82
100
  }
@@ -220,6 +220,11 @@ export interface Settings {
220
220
  allowedCommandPrefixes?: string[]; // Override built-in SAFE_COMMAND_PREFIXES for !command resolution (global-only — ignored in project settings)
221
221
  fetchAllowedUrls?: string[]; // Hostnames exempted from SSRF blocklist in fetch_page (global-only — ignored in project settings)
222
222
  hooks?: HooksSettings; // Layer 0 shell-command hooks. Project-scoped hooks require explicit trust (.pi/hooks.trusted).
223
+ seedDefaultsOnLaunch?: boolean; // When true, ship-with-OTTO default packages are added to `packages` on launch
224
+ seededDefaults?: string[]; // Sources already attempted to seed — prevents re-adding after the user removes one
225
+ enabledDefaultPackages?: string[]; // Subset of default sources the user opted into during onboarding. undefined = "all" (back-compat)
226
+ quietExtensions?: string[]; // Substring patterns matched against extension.path — ui.notify calls from matching extensions are silently dropped
227
+ seededQuietPatterns?: string[]; // Quiet patterns already attempted to seed — zombie-resurrection guard, mirrors seededDefaults
223
228
  }
224
229
 
225
230
  /** Settings keys that are only respected from global config — project settings cannot override these. */
@@ -938,6 +943,57 @@ export class SettingsManager {
938
943
  this.setProjectSetting("packages", packages);
939
944
  }
940
945
 
946
+ getSeedDefaultsOnLaunch(): boolean | undefined {
947
+ return this.settings.seedDefaultsOnLaunch;
948
+ }
949
+
950
+ setSeedDefaultsOnLaunch(enabled: boolean): void {
951
+ this.setGlobalSetting("seedDefaultsOnLaunch", enabled);
952
+ }
953
+
954
+ getSeededDefaults(): string[] {
955
+ return [...(this.settings.seededDefaults ?? [])];
956
+ }
957
+
958
+ setSeededDefaults(sources: string[]): void {
959
+ this.setGlobalSetting("seededDefaults", sources);
960
+ }
961
+
962
+ /**
963
+ * Returns undefined when the user has expressed no preference (back-compat:
964
+ * the seeder treats this as "install every default"). Returns a (possibly
965
+ * empty) array when the user has explicitly chosen a subset.
966
+ */
967
+ getEnabledDefaultPackages(): string[] | undefined {
968
+ const value = this.settings.enabledDefaultPackages;
969
+ return value === undefined ? undefined : [...value];
970
+ }
971
+
972
+ setEnabledDefaultPackages(sources: string[]): void {
973
+ this.setGlobalSetting("enabledDefaultPackages", sources);
974
+ }
975
+
976
+ /**
977
+ * Returns substring patterns matched (case-insensitive) against an extension's
978
+ * filesystem path. ui.notify calls from matching extensions are silently
979
+ * dropped at runtime — useful for muting noisy session_start banners.
980
+ */
981
+ getQuietExtensions(): string[] {
982
+ return [...(this.settings.quietExtensions ?? [])];
983
+ }
984
+
985
+ setQuietExtensions(patterns: string[]): void {
986
+ this.setGlobalSetting("quietExtensions", patterns);
987
+ }
988
+
989
+ getSeededQuietPatterns(): string[] {
990
+ return [...(this.settings.seededQuietPatterns ?? [])];
991
+ }
992
+
993
+ setSeededQuietPatterns(patterns: string[]): void {
994
+ this.setGlobalSetting("seededQuietPatterns", patterns);
995
+ }
996
+
941
997
  getExtensionPaths(): string[] {
942
998
  return [...(this.settings.extensions ?? [])];
943
999
  }