@oh-my-pi/pi-coding-agent 17.2.3 → 17.2.4

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 (92) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/{CHANGELOG-c5hpqt9r.md → CHANGELOG-bpmhv26t.md} +41 -0
  3. package/dist/cli.js +3270 -3270
  4. package/dist/types/advisor/advise-tool.d.ts +6 -0
  5. package/dist/types/advisor/runtime.d.ts +4 -4
  6. package/dist/types/capability/mcp.d.ts +9 -0
  7. package/dist/types/cli/models-cli.d.ts +1 -1
  8. package/dist/types/config/keybindings.d.ts +1 -1
  9. package/dist/types/config/settings-schema.d.ts +1 -1
  10. package/dist/types/discovery/helpers.d.ts +6 -0
  11. package/dist/types/discovery/omp-extension-roots.d.ts +21 -2
  12. package/dist/types/extensibility/extensions/loader.d.ts +6 -2
  13. package/dist/types/extensibility/plugins/marketplace/registry.d.ts +2 -2
  14. package/dist/types/launch/paths.d.ts +2 -1
  15. package/dist/types/mcp/request-id.d.ts +19 -0
  16. package/dist/types/mcp/types.d.ts +14 -0
  17. package/dist/types/memory-backend/index.d.ts +1 -0
  18. package/dist/types/memory-backend/messages.d.ts +15 -0
  19. package/dist/types/modes/components/model-browser.d.ts +7 -5
  20. package/dist/types/modes/components/model-picker.d.ts +9 -3
  21. package/dist/types/modes/components/read-tool-group.d.ts +9 -0
  22. package/dist/types/modes/types.d.ts +1 -1
  23. package/dist/types/registry/agent-lifecycle.d.ts +14 -5
  24. package/dist/types/registry/agent-registry.d.ts +4 -3
  25. package/dist/types/sdk.d.ts +1 -1
  26. package/dist/types/secrets/index.d.ts +6 -8
  27. package/dist/types/session/agent-session.d.ts +6 -1
  28. package/dist/types/session/model-controls.d.ts +0 -1
  29. package/dist/types/session/session-advisors.d.ts +7 -0
  30. package/dist/types/session/session-maintenance.d.ts +1 -0
  31. package/dist/types/session/session-manager.d.ts +11 -5
  32. package/dist/types/system-prompt.d.ts +2 -0
  33. package/dist/types/tools/default-renderer.d.ts +3 -0
  34. package/package.json +12 -12
  35. package/scripts/legacy-pi-virtual-module.ts +12 -2
  36. package/src/advisor/advise-tool.ts +18 -0
  37. package/src/advisor/runtime.ts +6 -6
  38. package/src/capability/mcp.ts +7 -0
  39. package/src/cli/models-cli.ts +13 -16
  40. package/src/collab/host.ts +1 -1
  41. package/src/config/keybindings.ts +14 -9
  42. package/src/config/mcp-schema.json +5 -0
  43. package/src/config/model-registry.ts +7 -1
  44. package/src/config/settings-schema.ts +1 -1
  45. package/src/discovery/builtin.ts +10 -0
  46. package/src/discovery/helpers.ts +10 -0
  47. package/src/discovery/mcp-json.ts +11 -1
  48. package/src/discovery/omp-extension-roots.ts +79 -20
  49. package/src/discovery/omp-plugins.ts +10 -1
  50. package/src/extensibility/extensions/loader.ts +66 -28
  51. package/src/extensibility/plugins/marketplace/registry.ts +4 -8
  52. package/src/launch/paths.ts +2 -5
  53. package/src/main.ts +24 -22
  54. package/src/mcp/config.ts +1 -0
  55. package/src/mcp/request-id.ts +24 -0
  56. package/src/mcp/transports/http.ts +4 -2
  57. package/src/mcp/transports/sse.ts +4 -2
  58. package/src/mcp/transports/stdio.ts +4 -2
  59. package/src/mcp/types.ts +15 -0
  60. package/src/memory-backend/index.ts +1 -0
  61. package/src/memory-backend/messages.ts +19 -0
  62. package/src/modes/components/agent-hub.ts +1 -1
  63. package/src/modes/components/custom-editor.ts +1 -1
  64. package/src/modes/components/model-browser.ts +25 -17
  65. package/src/modes/components/model-picker.ts +10 -6
  66. package/src/modes/components/read-tool-group.ts +26 -0
  67. package/src/modes/components/tool-execution.ts +77 -20
  68. package/src/modes/controllers/command-controller.ts +2 -2
  69. package/src/modes/controllers/event-controller.ts +182 -10
  70. package/src/modes/controllers/selector-controller.ts +27 -6
  71. package/src/modes/types.ts +5 -1
  72. package/src/prompts/tools/bash.md +1 -1
  73. package/src/registry/agent-lifecycle.ts +36 -11
  74. package/src/registry/agent-registry.ts +12 -5
  75. package/src/sdk.ts +68 -29
  76. package/src/secrets/index.ts +15 -17
  77. package/src/session/agent-session.ts +10 -1
  78. package/src/session/agent-storage.ts +6 -4
  79. package/src/session/history-storage.ts +4 -2
  80. package/src/session/messages.ts +31 -7
  81. package/src/session/model-controls.ts +0 -1
  82. package/src/session/session-advisors.ts +19 -1
  83. package/src/session/session-maintenance.ts +3 -0
  84. package/src/session/session-manager.ts +128 -76
  85. package/src/session/session-tools.ts +25 -23
  86. package/src/slash-commands/builtin-registry.ts +2 -2
  87. package/src/system-prompt.ts +6 -1
  88. package/src/task/executor.ts +8 -1
  89. package/src/tiny/text.ts +12 -0
  90. package/src/tools/default-renderer.ts +19 -4
  91. package/src/tools/index.ts +10 -6
  92. package/src/web/search/providers/anthropic.ts +3 -1
@@ -405,11 +405,17 @@ export class SessionPersistenceIndeterminateError extends AggregateError {
405
405
  * tree by `(id, parentId)`, and the mutable leaf pointer selects which path is
406
406
  * active for future appends and for LLM context construction.
407
407
  *
408
- * Durability is software-crash safe but not power-loss safe: appends are handed
409
- * to the OS synchronously in-body (so an entry survives an OOM/SIGKILL the
410
- * instant `appendMessage` returns) but never `fsync`'d. Full-file rewrites go
411
- * through the storage layer's atomic temp-write+rename so a crash mid-rewrite
412
- * cannot truncate the prior good file.
408
+ * Durability is software-crash safe but not power-loss safe: appends are normally
409
+ * handed to the OS synchronously in-body but never `fsync`'d. While an in-place
410
+ * atomic rewrite publishes, a synchronous `flushSync` still persists fenced
411
+ * appends by superseding the pending publish against the same (stable) path.
412
+ * A {@link moveTo} relocation is the one exception: the session file is being
413
+ * renamed to a new path, so appends landing in the rename window are held in
414
+ * memory and persisted only by the move's trailing rewrite. A `flushSync` in
415
+ * that narrow window cannot durably place them — writing the vacated source
416
+ * would recreate the orphan the move eliminates, and a queued backend publish
417
+ * (fire-and-forget on `IndexedSessionStorage`) could land after the rename all
418
+ * the same. This gap is inherent to closing the writer for a Windows-safe rename.
413
419
  */
414
420
  export class SessionManager {
415
421
  #cwd: string;
@@ -477,6 +483,16 @@ export class SessionManager {
477
483
  #atomicRewriteFenceEpoch: number | null = null;
478
484
  /** Set by synchronous appends that land while an atomic replacement is active. */
479
485
  #atomicRewriteDirty = false;
486
+ /**
487
+ * True while {@link moveTo} is renaming the session file but has not yet
488
+ * repointed `#sessionFile`. Both the synchronous append hot path
489
+ * ({@link #appendToSessionFile}) and synchronous rewrites
490
+ * ({@link #rewriteSynchronously}, reached via `flushSync` on Ctrl+C) would
491
+ * otherwise touch the pre-rename path — opening a fresh writer or writing the
492
+ * full body — recreating the very orphan the move eliminates. Both defer
493
+ * while this is set; the move's trailing atomic rewrite persists them.
494
+ */
495
+ #sessionFileRelocating = false;
480
496
  /** Atomic entry batch currently staged for a full-file commit. */
481
497
  #atomicEntryBatch: AtomicEntryBatch | undefined;
482
498
 
@@ -754,6 +770,12 @@ export class SessionManager {
754
770
  */
755
771
  #rewriteSynchronously(): void {
756
772
  if (!this.#persist || !this.#sessionFile || !this.#shouldHaveSessionFile()) return;
773
+ // A move is renaming the file out from under `#sessionFile`. Writing the
774
+ // full body now would target either the vacated source (recreating the
775
+ // orphan the move eliminates) or a path a queued backend publish may clobber
776
+ // post-rename, so defer to moveTo's trailing atomic rewrite. This narrows
777
+ // flushSync durability for entries in the rename window (see class doc).
778
+ if (this.#sessionFileRelocating) return;
757
779
 
758
780
  try {
759
781
  const body = this.#fileBody();
@@ -851,13 +873,18 @@ export class SessionManager {
851
873
  }
852
874
 
853
875
  // Atomic replacement window: the old path may be moved aside underneath
854
- // any newly-opened append handle (Windows EPERM fallback). Do not open a
855
- // writer here; the active rewrite loops and serializes a fresh full body.
876
+ // any newly-opened append handle (Windows EPERM fallback), or a `moveTo`
877
+ // may be renaming the session file to a new directory. Do not open a
878
+ // writer here; the active rewrite loops and serializes a fresh full body,
879
+ // and the move's trailing atomic rewrite folds in these deferred entries.
856
880
  // A superseding synchronous rewrite bumps `#diskEpoch`, at which point
857
881
  // the pending atomic publish is guaranteed to abandon via its
858
882
  // `commitGuard`, so appends can (and must) take the hot path so they
859
883
  // don't strand in memory while `close()` returns without a rewrite.
860
- if (this.#atomicRewriteFenceEpoch !== null && this.#atomicRewriteFenceEpoch === this.#diskEpoch) {
884
+ if (
885
+ this.#sessionFileRelocating ||
886
+ (this.#atomicRewriteFenceEpoch !== null && this.#atomicRewriteFenceEpoch === this.#diskEpoch)
887
+ ) {
861
888
  this.#fileIsCurrent = false;
862
889
  this.#rewriteRequired = true;
863
890
  this.#atomicRewriteDirty = true;
@@ -893,6 +920,16 @@ export class SessionManager {
893
920
  return;
894
921
  }
895
922
 
923
+ // Title changes use their own asynchronous append path rather than
924
+ // #appendToSessionFile. Defer them under the same move fence so they cannot
925
+ // recreate the vacated source path before #sessionFile is repointed.
926
+ if (this.#sessionFileRelocating) {
927
+ this.#fileIsCurrent = false;
928
+ this.#rewriteRequired = true;
929
+ this.#atomicRewriteDirty = true;
930
+ return;
931
+ }
932
+
896
933
  if (
897
934
  !this.#fileIsCurrent ||
898
935
  this.#rewriteRequired ||
@@ -1325,90 +1362,105 @@ export class SessionManager {
1325
1362
  : computeDefaultSessionDir(resolvedCwd, this.#storage));
1326
1363
 
1327
1364
  let sessionFileExisted = false;
1328
-
1365
+ // Fence synchronous appends and rewrites away from the session file for the
1366
+ // duration of the relocation (see `#sessionFileRelocating`). This flag —
1367
+ // not a `#diskEpoch` bump — is used deliberately: bumping the epoch here
1368
+ // would cancel any disk task already queued at the current epoch (e.g. a
1369
+ // header-only `ensureOnDisk()` materializing rewrite) before the drain
1370
+ // below runs it, silently dropping an explicitly materialized session.
1329
1371
  if (this.#persist && this.#sessionFile) {
1330
- this.#storage.ensureDirSync(nextSessionDir);
1331
- await this.#drainAndCloseWriter();
1332
- this.#clearDiskError();
1333
-
1334
- const oldSessionFile = this.#sessionFile;
1335
- const newSessionFile = path.join(nextSessionDir, path.basename(oldSessionFile));
1336
- const oldArtifactsDir = artifactsDirectoryFor(oldSessionFile)!;
1337
- const newArtifactsDir = artifactsDirectoryFor(newSessionFile)!;
1338
- const sessionPathChanged = path.resolve(oldSessionFile) !== path.resolve(newSessionFile);
1339
- const artifactPathChanged = path.resolve(oldArtifactsDir) !== path.resolve(newArtifactsDir);
1340
- sessionFileExisted = this.#storage.existsSync(oldSessionFile);
1372
+ this.#sessionFileRelocating = true;
1373
+ }
1341
1374
 
1342
- let sessionMoved = false;
1343
- let artifactsMoved = false;
1375
+ try {
1376
+ if (this.#persist && this.#sessionFile) {
1377
+ this.#storage.ensureDirSync(nextSessionDir);
1378
+ await this.#drainAndCloseWriter();
1379
+ this.#clearDiskError();
1380
+
1381
+ const oldSessionFile = this.#sessionFile;
1382
+ const newSessionFile = path.join(nextSessionDir, path.basename(oldSessionFile));
1383
+ const oldArtifactsDir = artifactsDirectoryFor(oldSessionFile)!;
1384
+ const newArtifactsDir = artifactsDirectoryFor(newSessionFile)!;
1385
+ const sessionPathChanged = path.resolve(oldSessionFile) !== path.resolve(newSessionFile);
1386
+ const artifactPathChanged = path.resolve(oldArtifactsDir) !== path.resolve(newArtifactsDir);
1387
+ sessionFileExisted = this.#storage.existsSync(oldSessionFile);
1388
+
1389
+ let sessionMoved = false;
1390
+ let artifactsMoved = false;
1344
1391
 
1345
- try {
1346
- if (sessionFileExisted && sessionPathChanged) {
1347
- await fs.promises.rename(oldSessionFile, newSessionFile);
1348
- sessionMoved = true;
1349
- }
1392
+ try {
1393
+ if (sessionFileExisted && sessionPathChanged) {
1394
+ await fs.promises.rename(oldSessionFile, newSessionFile);
1395
+ sessionMoved = true;
1396
+ }
1350
1397
 
1351
- if (artifactPathChanged) {
1352
- try {
1353
- const artifactStat = await fs.promises.stat(oldArtifactsDir);
1354
- if (artifactStat.isDirectory()) {
1355
- await fs.promises.rename(oldArtifactsDir, newArtifactsDir);
1356
- artifactsMoved = true;
1398
+ if (artifactPathChanged) {
1399
+ try {
1400
+ const artifactStat = await fs.promises.stat(oldArtifactsDir);
1401
+ if (artifactStat.isDirectory()) {
1402
+ await fs.promises.rename(oldArtifactsDir, newArtifactsDir);
1403
+ artifactsMoved = true;
1404
+ }
1405
+ } catch (err) {
1406
+ if (!isEnoent(err)) throw err;
1357
1407
  }
1358
- } catch (err) {
1359
- if (!isEnoent(err)) throw err;
1360
1408
  }
1361
- }
1362
- } catch (err) {
1363
- if (artifactsMoved) {
1364
- try {
1365
- await fs.promises.rename(newArtifactsDir, oldArtifactsDir);
1366
- } catch (rollbackErr) {
1367
- throw new Error(
1368
- `Failed to move artifacts and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
1369
- );
1409
+ } catch (err) {
1410
+ if (artifactsMoved) {
1411
+ try {
1412
+ await fs.promises.rename(newArtifactsDir, oldArtifactsDir);
1413
+ } catch (rollbackErr) {
1414
+ throw new Error(
1415
+ `Failed to move artifacts and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
1416
+ );
1417
+ }
1370
1418
  }
1371
- }
1372
1419
 
1373
- if (sessionMoved) {
1374
- try {
1375
- await fs.promises.rename(newSessionFile, oldSessionFile);
1376
- } catch (rollbackErr) {
1377
- throw new Error(
1378
- `Failed to move session file and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
1379
- );
1420
+ if (sessionMoved) {
1421
+ try {
1422
+ await fs.promises.rename(newSessionFile, oldSessionFile);
1423
+ } catch (rollbackErr) {
1424
+ throw new Error(
1425
+ `Failed to move session file and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
1426
+ );
1427
+ }
1380
1428
  }
1429
+
1430
+ throw err;
1381
1431
  }
1382
1432
 
1383
- throw err;
1433
+ this.#sessionFile = newSessionFile;
1434
+ this.#artifactManager = null;
1435
+ this.#artifactManagerSessionFile = null;
1436
+ // Path is repointed; a sync rewrite may now safely target the new file.
1437
+ this.#sessionFileRelocating = false;
1384
1438
  }
1385
1439
 
1386
- this.#sessionFile = newSessionFile;
1387
- this.#artifactManager = null;
1388
- this.#artifactManagerSessionFile = null;
1389
- }
1440
+ this.#cwd = resolvedCwd;
1441
+ this.#sessionDir = nextSessionDir;
1442
+ this.#header.cwd = resolvedCwd;
1443
+ // Re-filter additional roots: the new cwd may have been an additional root,
1444
+ // or it may now contain/subsume one. Re-normalize to keep the invariant
1445
+ // that cwd is never also listed as an additional directory.
1446
+ if (this.#additionalDirectories.length > 0) {
1447
+ this.#additionalDirectories = this.#additionalDirectories.filter(d => d !== resolvedCwd);
1448
+ this.#header.additionalDirectories =
1449
+ this.#additionalDirectories.length > 0 ? this.#additionalDirectories : undefined;
1450
+ }
1390
1451
 
1391
- this.#cwd = resolvedCwd;
1392
- this.#sessionDir = nextSessionDir;
1393
- this.#header.cwd = resolvedCwd;
1394
- // Re-filter additional roots: the new cwd may have been an additional root,
1395
- // or it may now contain/subsume one. Re-normalize to keep the invariant
1396
- // that cwd is never also listed as an additional directory.
1397
- if (this.#additionalDirectories.length > 0) {
1398
- this.#additionalDirectories = this.#additionalDirectories.filter(d => d !== resolvedCwd);
1399
- this.#header.additionalDirectories =
1400
- this.#additionalDirectories.length > 0 ? this.#additionalDirectories : undefined;
1401
- }
1452
+ // Rewrite at the new location when the file already existed (update cwd) or
1453
+ // there is in-memory output worth materializing; otherwise stay lazy.
1454
+ const hasAssistant = this.#historyContainsAssistantMessage();
1455
+ if (this.#persist && this.#sessionFile && (sessionFileExisted || hasAssistant)) {
1456
+ this.#forceFileCreation = true;
1457
+ await this.#rewriteAtomically();
1458
+ }
1402
1459
 
1403
- // Rewrite at the new location when the file already existed (update cwd) or
1404
- // there is in-memory output worth materializing; otherwise stay lazy.
1405
- const hasAssistant = this.#historyContainsAssistantMessage();
1406
- if (this.#persist && this.#sessionFile && (sessionFileExisted || hasAssistant)) {
1407
- this.#forceFileCreation = true;
1408
- await this.#rewriteAtomically();
1460
+ if (this.#sessionFile) this.#rememberBreadcrumb(resolvedCwd, this.#sessionFile);
1461
+ } finally {
1462
+ this.#sessionFileRelocating = false;
1409
1463
  }
1410
-
1411
- if (this.#sessionFile) this.#rememberBreadcrumb(resolvedCwd, this.#sessionFile);
1412
1464
  }
1413
1465
 
1414
1466
  /**
@@ -574,24 +574,28 @@ export class SessionTools {
574
574
  /** Applies an enabled tool set and reconciles its `xd://` partition. */
575
575
  async applyActiveToolsByName(toolNames: string[]): Promise<void> {
576
576
  toolNames = normalizeToolNames(toolNames);
577
+ let builtInWriteAvailable = this.#builtInToolNames.has("write");
578
+ if (toolNames.includes("write") && !builtInWriteAvailable) {
579
+ builtInWriteAvailable = (await this.#ensureWriteRegistered?.()) === true;
580
+ if (builtInWriteAvailable) this.#builtInToolNames.add("write");
581
+ }
577
582
  const selectedTools = toolNames.flatMap(name => {
578
583
  const tool = this.#toolRegistry.get(name);
579
584
  return tool ? [{ name, tool }] : [];
580
585
  });
581
586
  const xdevReadAvailable = this.#builtInToolNames.has("read") && selectedTools.some(({ name }) => name === "read");
587
+ const xdevWriteAvailable = builtInWriteAvailable && selectedTools.some(({ name }) => name === "write");
582
588
  const isPresentationPinned = (name: string): boolean =>
583
589
  this.#presentationPinnedToolNames?.has(name) === true || this.#runtimeSelectedToolNames?.has(name) === true;
584
590
  const mountCandidates = selectedTools.filter(
585
591
  ({ name, tool }) =>
586
- this.#xdev !== undefined && xdevReadAvailable && !isPresentationPinned(name) && isMountableUnderXdev(tool),
592
+ this.#xdev !== undefined &&
593
+ xdevReadAvailable &&
594
+ xdevWriteAvailable &&
595
+ !isPresentationPinned(name) &&
596
+ isMountableUnderXdev(tool),
587
597
  );
588
-
589
- let builtInWriteAvailable = this.#builtInToolNames.has("write");
590
- if (mountCandidates.length > 0 && !builtInWriteAvailable) {
591
- builtInWriteAvailable = (await this.#ensureWriteRegistered?.()) === true;
592
- if (builtInWriteAvailable) this.#builtInToolNames.add("write");
593
- }
594
- const mountNames = builtInWriteAvailable ? new Set(mountCandidates.map(({ name }) => name)) : new Set<string>();
598
+ const mountNames = new Set(mountCandidates.map(({ name }) => name));
595
599
  const tools: AgentTool[] = [];
596
600
  const validToolNames: string[] = [];
597
601
  for (const { name, tool } of selectedTools) {
@@ -831,23 +835,21 @@ export class SessionTools {
831
835
 
832
836
  /** Rediscovers reloadable skills and refreshes prompt metadata. */
833
837
  async refreshSkills(): Promise<void> {
834
- if (!this.#skillsReloadable) {
835
- return;
836
- }
837
-
838
838
  resetCapabilities();
839
- const skillsSettings = this.#host.settings.getGroup("skills");
840
- const discovered = await loadSkills({
841
- ...skillsSettings,
842
- cwd: this.#host.sessionManager.getCwd(),
843
- disabledExtensions: this.#host.settings.get("disabledExtensions") ?? [],
844
- });
845
- this.#skills = discovered.skills;
846
- this.#skillWarnings = discovered.warnings;
847
- this.#skillsSettings = skillsSettings;
839
+ if (this.#skillsReloadable) {
840
+ const skillsSettings = this.#host.settings.getGroup("skills");
841
+ const discovered = await loadSkills({
842
+ ...skillsSettings,
843
+ cwd: this.#host.sessionManager.getCwd(),
844
+ disabledExtensions: this.#host.settings.get("disabledExtensions") ?? [],
845
+ });
846
+ this.#skills = discovered.skills;
847
+ this.#skillWarnings = discovered.warnings;
848
+ this.#skillsSettings = skillsSettings;
848
849
 
849
- if (this.#host.agentKind() === "main") {
850
- setActiveSkills(this.#skills);
850
+ if (this.#host.agentKind() === "main") {
851
+ setActiveSkills(this.#skills);
852
+ }
851
853
  }
852
854
  await this.refreshBaseSystemPrompt();
853
855
  this.#host.notifyCommandMetadataChanged();
@@ -32,7 +32,7 @@ import {
32
32
  MarketplaceManager,
33
33
  } from "../extensibility/plugins/marketplace";
34
34
  import { readMCPConfigFile } from "../mcp/config-writer";
35
- import { resolveMemoryBackend } from "../memory-backend";
35
+ import { memoryStatsUnavailableMessage, resolveMemoryBackend } from "../memory-backend";
36
36
  import { runPauseScreen } from "../modes/components/pause-screen";
37
37
  import { collectMcpServerNames, MCPCommandController } from "../modes/controllers/mcp-command-controller";
38
38
  import { describeLoopLimitRuntime } from "../modes/loop-limit";
@@ -1947,7 +1947,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1947
1947
  case "diagnose": {
1948
1948
  const hook = verb === "stats" ? backend.stats : backend.diagnose;
1949
1949
  const payload = await hook?.(runtime.settings.getAgentDir(), runtime.cwd, runtime.session);
1950
- await runtime.output(payload ?? `Memory ${verb} is not available for the ${backend.id} backend.`);
1950
+ await runtime.output(payload ?? memoryStatsUnavailableMessage(backend.id, verb));
1951
1951
  return commandConsumed();
1952
1952
  }
1953
1953
  case "mm":
@@ -332,6 +332,8 @@ export async function resolvePromptInput(input: string | undefined, description:
332
332
  export interface LoadContextFilesOptions {
333
333
  /** Working directory to start walking up from. Default: getProjectDir() */
334
334
  cwd?: string;
335
+ /** Disabled extension IDs to honor instead of the process-global settings. */
336
+ disabledExtensions?: string[];
335
337
  }
336
338
 
337
339
  function dedupeExactContextFiles(
@@ -356,7 +358,10 @@ export async function loadProjectContextFiles(
356
358
  ): Promise<Array<{ path: string; content: string; depth?: number }>> {
357
359
  const resolvedCwd = options.cwd ?? getProjectDir();
358
360
 
359
- const result = await loadCapability(contextFileCapability.id, { cwd: resolvedCwd });
361
+ const result = await loadCapability(contextFileCapability.id, {
362
+ cwd: resolvedCwd,
363
+ disabledExtensions: options.disabledExtensions,
364
+ });
360
365
 
361
366
  // Materialize ContextFile items, expanding any `@path/to/file` includes
362
367
  // in their content. The expansion uses the file's own directory as the
@@ -2364,7 +2364,14 @@ export async function finalizeSubagentLifecycle(args: {
2364
2364
  const resumableAbort =
2365
2365
  args.abortKind === "budget" && args.keepAlive && !args.isolated && args.reviveSession !== null;
2366
2366
  if (args.aborted && !resumableAbort) {
2367
- if (ref && ownsRef) registry.setStatus(args.id, "aborted", ref);
2367
+ if (ref && ownsRef) {
2368
+ // Terminal hard kill: mark `aborted` and detach the session before
2369
+ // disposing so the ref satisfies the AgentRef invariant (session null
2370
+ // when aborted) — ensureLive/hub focus must treat it as terminal, never
2371
+ // route into the disposed session.
2372
+ registry.setStatus(args.id, "aborted", ref);
2373
+ registry.detachSession(args.id, ref);
2374
+ }
2368
2375
  await disposeSession();
2369
2376
  return;
2370
2377
  }
package/src/tiny/text.ts CHANGED
@@ -142,6 +142,17 @@ export function isLowSignalTitleInput(message: string): boolean {
142
142
  */
143
143
  export const NO_TITLE_SENTINEL = "none";
144
144
 
145
+ /**
146
+ * Upper bounds on an accepted title. Titling is a 3-7 word task, so any output
147
+ * past these limits is a model that ignored the task and answered the user's
148
+ * message instead — its whole reply must not become the session title (issue
149
+ * #7303). Rejecting (return null) lets the caller defer titling to the next
150
+ * user turn; truncating would keep half an assistant blob, which is still an
151
+ * assistant blob.
152
+ */
153
+ const MAX_TITLE_CHARS = 80;
154
+ const MAX_TITLE_WORDS = 12;
155
+
145
156
  export function normalizeGeneratedTitle(value: string | null | undefined, sourceText?: string): string | null {
146
157
  const firstLine = value?.trim().split(/\r?\n/, 1)[0]?.trim();
147
158
  if (!firstLine) return null;
@@ -154,6 +165,7 @@ export function normalizeGeneratedTitle(value: string | null | undefined, source
154
165
  .replace(/[.!?]$/, "")
155
166
  .trim();
156
167
  if (!title || title.toLowerCase() === NO_TITLE_SENTINEL) return null;
168
+ if (title.length > MAX_TITLE_CHARS || (title.match(TITLE_WORD)?.length ?? 0) > MAX_TITLE_WORDS) return null;
157
169
  return sourceText === undefined ? title : reconcileTitleCasing(title, sourceText);
158
170
  }
159
171
 
@@ -25,6 +25,9 @@ export interface DefaultToolRenderInput {
25
25
  result?: {
26
26
  output: string;
27
27
  isError?: boolean;
28
+ /** Synthetic placeholder for a call skipped mid-batch to service steering/peer
29
+ * input — the tool never ran, so it renders neutral (info) rather than as an error. */
30
+ skipped?: boolean;
28
31
  };
29
32
  /** Current expansion and lifecycle state. */
30
33
  options: RenderResultOptions;
@@ -42,10 +45,22 @@ export function formatDefaultToolExecution(
42
45
  ? options.spinnerFrame !== undefined
43
46
  ? "running"
44
47
  : "pending"
45
- : result?.isError
46
- ? "error"
47
- : "done";
48
- lines.push(renderStatusLine({ icon, spinnerFrame: options.spinnerFrame, title: input.label }, uiTheme));
48
+ : result?.skipped
49
+ ? "info"
50
+ : result?.isError
51
+ ? "error"
52
+ : "done";
53
+ lines.push(
54
+ renderStatusLine(
55
+ {
56
+ icon,
57
+ spinnerFrame: options.spinnerFrame,
58
+ title: input.label,
59
+ ...(result?.skipped ? { titleColor: "muted" as const } : {}),
60
+ },
61
+ uiTheme,
62
+ ),
63
+ );
49
64
 
50
65
  const args = isRecord(input.args) ? input.args : undefined;
51
66
  if (!options.expanded && args && Object.keys(args).length > 0) {
@@ -667,8 +667,12 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
667
667
  // Ordinary sessions use xd:// for discoverable built-ins, custom tools, and
668
668
  // MCP tools. Structured children must expose only their host-provided names,
669
669
  // so never allocate a registry that later SDK assembly could populate.
670
+ // The transport rides read/write, so a session granted no write tool never
671
+ // allocates xd:// state — its tools are exposed top-level directly instead
672
+ // of auto-granting a write transport the session was denied.
670
673
  // Explicitly requested built-ins retain their top-level presentation.
671
- const xdevEnabled = !restrictToolNames && session.settings.get("tools.xdev");
674
+ const xdevEnabled =
675
+ !restrictToolNames && session.settings.get("tools.xdev") && tools.some(tool => tool.name === "write");
672
676
  const mountBuiltinTools = requestedTools === undefined;
673
677
  if (xdevEnabled) {
674
678
  const mountedNames = new Set<string>();
@@ -686,14 +690,14 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
686
690
  };
687
691
  tools = kept;
688
692
  }
689
- // The xd:// transport rides read/write: `read xd://` lists+documents devices,
690
- // `write xd://<tool>` executes them. Staged previews from deferrable tools
691
- // (e.g. ast_edit) also resolve through a `write` to xd://resolve/reject. Retain
692
- // both whenever any device is mounted or a deferrable tool can stage one.
693
+ // Staged previews from deferrable tools (e.g. ast_edit) resolve through a
694
+ // `write` to xd://resolve/reject, so retain write whenever one can stage.
695
+ // xd:// mounting itself never registers write: sessions without a granted
696
+ // write tool skip mounting entirely (see xdevEnabled above).
693
697
  const xdevMounted = (session.xdev?.mountedNames.size ?? 0) > 0;
694
698
  if (
695
699
  !restrictToolNames &&
696
- (tools.some(tool => tool.deferrable === true) || xdevMounted) &&
700
+ tools.some(tool => tool.deferrable === true) &&
697
701
  !tools.some(tool => tool.name === "write")
698
702
  ) {
699
703
  const writeTool = await logger.time("createTools:write", BUILTIN_TOOLS.write, session);
@@ -19,6 +19,7 @@ import {
19
19
  withAuth,
20
20
  wrapFetchForCch,
21
21
  } from "@oh-my-pi/pi-ai";
22
+ import { hasOpus47ApiRestrictions } from "@oh-my-pi/pi-catalog/identity/family";
22
23
  import { $env } from "@oh-my-pi/pi-utils";
23
24
  import type {
24
25
  AnthropicApiResponse,
@@ -176,7 +177,8 @@ async function callSearch(
176
177
  body.metadata = { user_id: metadataUserId };
177
178
  }
178
179
 
179
- if (temperature !== undefined) {
180
+ // Opus 4.7+, Sonnet 5+, and Fable/Mythos 5 reject sampling parameters with a 400.
181
+ if (temperature !== undefined && !hasOpus47ApiRestrictions(model)) {
180
182
  body.temperature = temperature;
181
183
  }
182
184