@oh-my-pi/pi-coding-agent 15.9.1 → 15.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +68 -2
  2. package/dist/types/cli/classify-install-target.d.ts +5 -1
  3. package/dist/types/cli/dry-balance-cli.d.ts +104 -0
  4. package/dist/types/commands/dry-balance.d.ts +31 -0
  5. package/dist/types/config/model-registry.d.ts +2 -0
  6. package/dist/types/config/models-config-schema.d.ts +3 -0
  7. package/dist/types/config/settings-schema.d.ts +13 -4
  8. package/dist/types/config/settings.d.ts +11 -0
  9. package/dist/types/discovery/helpers.d.ts +1 -0
  10. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -3
  11. package/dist/types/hindsight/bank.d.ts +17 -9
  12. package/dist/types/hindsight/mental-models.d.ts +1 -1
  13. package/dist/types/hindsight/state.d.ts +9 -3
  14. package/dist/types/mcp/manager.d.ts +1 -1
  15. package/dist/types/modes/components/assistant-message.d.ts +11 -0
  16. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  17. package/dist/types/modes/components/error-banner.d.ts +11 -0
  18. package/dist/types/modes/components/tool-execution.d.ts +15 -0
  19. package/dist/types/modes/components/transcript-container.d.ts +4 -2
  20. package/dist/types/modes/components/user-message.d.ts +1 -1
  21. package/dist/types/modes/image-references.d.ts +17 -0
  22. package/dist/types/modes/interactive-mode.d.ts +7 -0
  23. package/dist/types/modes/types.d.ts +7 -0
  24. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  25. package/dist/types/session/agent-session.d.ts +9 -0
  26. package/dist/types/session/auth-storage.d.ts +2 -2
  27. package/dist/types/session/blob-store.d.ts +12 -11
  28. package/dist/types/session/session-manager.d.ts +5 -3
  29. package/dist/types/system-prompt.d.ts +2 -0
  30. package/dist/types/task/types.d.ts +2 -0
  31. package/dist/types/tiny/title-client.d.ts +16 -1
  32. package/dist/types/tool-discovery/mode.d.ts +8 -0
  33. package/dist/types/tools/archive-reader.d.ts +5 -1
  34. package/dist/types/tools/index.d.ts +16 -0
  35. package/dist/types/tools/path-utils.d.ts +11 -0
  36. package/dist/types/tui/hyperlink.d.ts +12 -0
  37. package/dist/types/web/search/render.d.ts +1 -2
  38. package/package.json +9 -9
  39. package/src/cli/classify-install-target.ts +31 -5
  40. package/src/cli/dry-balance-cli.ts +823 -0
  41. package/src/cli/plugin-cli.ts +45 -0
  42. package/src/cli/web-search-cli.ts +0 -1
  43. package/src/cli-commands.ts +1 -0
  44. package/src/commands/dry-balance.ts +43 -0
  45. package/src/config/model-registry.ts +60 -4
  46. package/src/config/models-config-schema.ts +2 -0
  47. package/src/config/settings-schema.ts +14 -4
  48. package/src/config/settings.ts +38 -0
  49. package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -0
  50. package/src/discovery/github.ts +37 -1
  51. package/src/discovery/helpers.ts +3 -1
  52. package/src/eval/__tests__/agent-bridge.test.ts +72 -0
  53. package/src/eval/py/tool-bridge.ts +43 -5
  54. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +31 -2
  55. package/src/extensibility/plugins/legacy-pi-compat.ts +245 -25
  56. package/src/hindsight/backend.ts +184 -35
  57. package/src/hindsight/bank.ts +32 -22
  58. package/src/hindsight/mental-models.ts +1 -1
  59. package/src/hindsight/state.ts +21 -7
  60. package/src/internal-urls/docs-index.generated.ts +6 -6
  61. package/src/internal-urls/omp-protocol.ts +8 -2
  62. package/src/main.ts +7 -1
  63. package/src/mcp/manager.ts +40 -21
  64. package/src/modes/components/assistant-message.ts +22 -0
  65. package/src/modes/components/custom-editor.ts +14 -2
  66. package/src/modes/components/error-banner.ts +33 -0
  67. package/src/modes/components/tool-execution.ts +44 -0
  68. package/src/modes/components/transcript-container.ts +102 -30
  69. package/src/modes/components/tree-selector.ts +29 -2
  70. package/src/modes/components/user-message.ts +9 -2
  71. package/src/modes/controllers/event-controller.ts +42 -3
  72. package/src/modes/controllers/input-controller.ts +41 -3
  73. package/src/modes/image-references.ts +111 -0
  74. package/src/modes/interactive-mode.ts +48 -13
  75. package/src/modes/setup-wizard/scenes/sign-in.ts +27 -7
  76. package/src/modes/types.ts +10 -1
  77. package/src/modes/utils/ui-helpers.ts +23 -2
  78. package/src/prompts/agents/explore.md +1 -0
  79. package/src/prompts/agents/librarian.md +1 -0
  80. package/src/prompts/ci-green-request.md +5 -3
  81. package/src/prompts/dry-balance-bench.md +8 -0
  82. package/src/prompts/system/project-prompt.md +1 -0
  83. package/src/sdk.ts +99 -18
  84. package/src/session/agent-session.ts +103 -19
  85. package/src/session/auth-storage.ts +4 -0
  86. package/src/session/blob-store.ts +96 -9
  87. package/src/session/session-manager.ts +19 -10
  88. package/src/system-prompt.ts +4 -0
  89. package/src/task/executor.ts +6 -2
  90. package/src/task/index.ts +8 -7
  91. package/src/task/types.ts +2 -0
  92. package/src/tiny/title-client.ts +7 -1
  93. package/src/tool-discovery/mode.ts +24 -0
  94. package/src/tools/archive-reader.ts +339 -31
  95. package/src/tools/bash.ts +3 -4
  96. package/src/tools/fetch.ts +29 -9
  97. package/src/tools/gh.ts +65 -11
  98. package/src/tools/index.ts +22 -8
  99. package/src/tools/job.ts +3 -3
  100. package/src/tools/memory-reflect.ts +2 -2
  101. package/src/tools/path-utils.ts +21 -0
  102. package/src/tools/read.ts +58 -12
  103. package/src/tools/search-tool-bm25.ts +4 -6
  104. package/src/tools/search.ts +78 -12
  105. package/src/tui/hyperlink.ts +42 -7
  106. package/src/utils/file-mentions.ts +7 -107
  107. package/src/utils/title-generator.ts +58 -37
  108. package/src/web/search/index.ts +2 -2
  109. package/src/web/search/render.ts +20 -52
@@ -354,6 +354,7 @@ async function handleInstall(
354
354
  console.error(chalk.dim(` ${APP_NAME} plugin install name@marketplace`));
355
355
  console.error(chalk.dim(` ${APP_NAME} plugin install github:user/repo`));
356
356
  console.error(chalk.dim(` ${APP_NAME} plugin install https://github.com/user/repo#v1.0`));
357
+ console.error(chalk.dim(` ${APP_NAME} plugin install ./path/to/local/plugin`));
357
358
  process.exit(1);
358
359
  }
359
360
 
@@ -382,6 +383,49 @@ async function handleInstall(
382
383
  continue;
383
384
  }
384
385
 
386
+ if (target.type === "local") {
387
+ // Local paths route to link(): symlink the directory into the plugins
388
+ // node_modules tree so source edits show up without a reinstall. Matches
389
+ // `omp plugin link <path>` so users can use either verb interchangeably.
390
+ if (flags.scope) {
391
+ console.error(
392
+ chalk.yellow(
393
+ `Warning: --scope is only supported for marketplace installs (name@marketplace). Ignoring for ${spec}.`,
394
+ ),
395
+ );
396
+ }
397
+ if (flags.force) {
398
+ console.error(
399
+ chalk.yellow(
400
+ `Warning: --force has no effect for local path installs (link is already idempotent). Ignoring for ${spec}.`,
401
+ ),
402
+ );
403
+ }
404
+ if (flags.dryRun) {
405
+ if (flags.json) {
406
+ console.log(JSON.stringify({ dryRun: true, action: "link", path: target.path }, null, 2));
407
+ } else {
408
+ console.log(chalk.dim(`[dry-run] Would link ${spec}`));
409
+ }
410
+ continue;
411
+ }
412
+ try {
413
+ const result = await manager.link(target.path);
414
+ if (flags.json) {
415
+ console.log(JSON.stringify(result, null, 2));
416
+ } else {
417
+ console.log(chalk.green(`${theme.status.success} Linked ${result.name} from ${spec}`));
418
+ if (result.manifest.description) {
419
+ console.log(chalk.dim(` ${result.manifest.description}`));
420
+ }
421
+ }
422
+ } catch (err) {
423
+ console.error(chalk.red(`${theme.status.error} Failed to install ${spec}: ${err}`));
424
+ process.exit(1);
425
+ }
426
+ continue;
427
+ }
428
+
385
429
  // --scope only applies to marketplace installs; warn when it would be silently no-op'd for npm.
386
430
  if (flags.scope) {
387
431
  console.error(
@@ -923,6 +967,7 @@ ${chalk.bold("Sources:")}
923
967
  github:user/repo[#ref] GitHub shorthand (also gitlab:, bitbucket:, codeberg:, sourcehut:)
924
968
  https://github.com/user/repo Full git URL (https, ssh, or git protocol)
925
969
  name@marketplace Marketplace plugin (see marketplace command)
970
+ ./path, ../path, /abs, ~/path Local plugin directory (symlinked, same as plugin link)
926
971
 
927
972
  ${chalk.bold("Config Subcommands:")}
928
973
  config list <pkg> List all settings
@@ -97,7 +97,6 @@ export async function runSearchCommand(cmd: SearchCommandArgs): Promise<void> {
97
97
  const result = await runSearchQuery(params);
98
98
  const component = renderSearchResult(result, { expanded: cmd.expanded, isPartial: false }, theme, {
99
99
  query: cmd.query,
100
- allowLongAnswer: true,
101
100
  maxAnswerLines: cmd.expanded ? undefined : 6,
102
101
  });
103
102
 
@@ -20,6 +20,7 @@ export const commands: CommandEntry[] = [
20
20
  { name: "completions", load: () => import("./commands/completions").then(m => m.default) },
21
21
  { name: "__complete", load: () => import("./commands/complete").then(m => m.default) },
22
22
  { name: "config", load: () => import("./commands/config").then(m => m.default) },
23
+ { name: "dry-balance", load: () => import("./commands/dry-balance").then(m => m.default) },
23
24
  { name: "grep", load: () => import("./commands/grep").then(m => m.default) },
24
25
  { name: "grievances", load: () => import("./commands/grievances").then(m => m.default) },
25
26
  { name: "install", load: () => import("./commands/install").then(m => m.default) },
@@ -0,0 +1,43 @@
1
+ import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
2
+ import { runDryBalanceCommand } from "../cli/dry-balance-cli";
3
+
4
+ export default class DryBalance extends Command {
5
+ static description = "Dry-run OAuth account balancing across random session ids";
6
+
7
+ static args = {
8
+ model: Args.string({
9
+ description: "Model selector (provider/model or fuzzy id). Defaults to the configured default model.",
10
+ required: false,
11
+ }),
12
+ };
13
+
14
+ static flags = {
15
+ model: Flags.string({ description: "Model selector (same syntax as --model on omp)" }),
16
+ count: Flags.integer({ description: "Number of random session ids to try", default: 100 }),
17
+ concurrency: Flags.integer({ description: "Maximum concurrent credential resolutions", default: 32 }),
18
+ json: Flags.boolean({ description: "Output JSON" }),
19
+ bench: Flags.boolean({ description: "Send one live benchmark request per OAuth account" }),
20
+ };
21
+
22
+ static examples = [
23
+ "# Dry-run the configured default model with 100 random session ids\n omp dry-balance",
24
+ "# Dry-run a specific model\n omp dry-balance anthropic/claude-sonnet-4-5",
25
+ "# Larger run with bounded concurrency\n omp dry-balance --model openai-codex/gpt-5-codex --count 1000 --concurrency 64",
26
+ "# Benchmark every OAuth account in parallel\n omp dry-balance --bench",
27
+ "# Machine-readable output\n omp dry-balance --json",
28
+ ];
29
+
30
+ async run(): Promise<void> {
31
+ const { args, flags } = await this.parse(DryBalance);
32
+ await runDryBalanceCommand({
33
+ model: args.model,
34
+ flags: {
35
+ model: flags.model,
36
+ count: flags.count,
37
+ concurrency: flags.concurrency,
38
+ json: flags.json,
39
+ bench: flags.bench,
40
+ },
41
+ });
42
+ }
43
+ }
@@ -38,6 +38,45 @@ const DEFAULT_LOCAL_TOKEN = "lm-studio-local";
38
38
  // "socket connection was closed unexpectedly").
39
39
  const DISCOVERY_DEFAULT_MAX_TOKENS = 32_768;
40
40
 
41
+ const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434";
42
+ const OLLAMA_HOST_DEFAULT_PORT = "11434";
43
+
44
+ function normalizeOllamaHostEnv(value: string | undefined): string | undefined {
45
+ const trimmed = value?.trim();
46
+ if (!trimmed) return undefined;
47
+ const candidate = trimmed.includes("://")
48
+ ? trimmed
49
+ : trimmed.startsWith("//")
50
+ ? `http:${trimmed}`
51
+ : trimmed.startsWith(":")
52
+ ? `http://127.0.0.1${trimmed}`
53
+ : `http://${trimmed}`;
54
+ try {
55
+ const parsed = new URL(candidate);
56
+ if (!parsed.hostname || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
57
+ return undefined;
58
+ }
59
+ if (!parsed.port && parsed.protocol === "http:") {
60
+ parsed.port = OLLAMA_HOST_DEFAULT_PORT;
61
+ }
62
+ return `${parsed.protocol}//${parsed.host}`;
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ }
67
+
68
+ function getImplicitOllamaBaseUrl(): string {
69
+ const baseUrl = Bun.env.OLLAMA_BASE_URL?.trim();
70
+ return baseUrl || normalizeOllamaHostEnv(Bun.env.OLLAMA_HOST) || DEFAULT_OLLAMA_BASE_URL;
71
+ }
72
+
73
+ function getOllamaContextLengthOverride(): number | undefined {
74
+ const value = Bun.env.OLLAMA_CONTEXT_LENGTH?.trim();
75
+ if (!value) return undefined;
76
+ const parsed = Number(value);
77
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
78
+ }
79
+
41
80
  // Anthropic-safe variant of the discovery cap. The Anthropic stream converter
42
81
  // in `packages/ai/src/providers/anthropic.ts` derives the request limit as
43
82
  // `(model.maxTokens / 3) | 0`, so the 32K default would surface as 10,922
@@ -547,6 +586,7 @@ function applyModelOverride(model: Model<Api>, override: ModelOverride): Model<A
547
586
  if (override.input !== undefined) result.input = override.input as ("text" | "image")[];
548
587
  if (override.contextWindow !== undefined) result.contextWindow = override.contextWindow;
549
588
  if (override.maxTokens !== undefined) result.maxTokens = override.maxTokens;
589
+ if (override.omitMaxOutputTokens !== undefined) result.omitMaxOutputTokens = override.omitMaxOutputTokens;
550
590
  if (override.contextPromotionTarget !== undefined) result.contextPromotionTarget = override.contextPromotionTarget;
551
591
  if (override.premiumMultiplier !== undefined) result.premiumMultiplier = override.premiumMultiplier;
552
592
  if (override.cost) {
@@ -575,6 +615,7 @@ interface CustomModelDefinitionLike {
575
615
  cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
576
616
  contextWindow?: number;
577
617
  maxTokens?: number;
618
+ omitMaxOutputTokens?: boolean;
578
619
  headers?: Record<string, string>;
579
620
  compat?: Model<Api>["compat"];
580
621
  contextPromotionTarget?: string;
@@ -597,6 +638,7 @@ type CustomModelOverlay = {
597
638
  cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
598
639
  contextWindow?: number;
599
640
  maxTokens?: number;
641
+ omitMaxOutputTokens?: boolean;
600
642
  headers?: Record<string, string>;
601
643
  compat?: Model<Api>["compat"];
602
644
  contextPromotionTarget?: string;
@@ -667,6 +709,7 @@ function buildCustomModelOverlay(
667
709
  cost: modelDef.cost,
668
710
  contextWindow: modelDef.contextWindow,
669
711
  maxTokens: modelDef.maxTokens,
712
+ omitMaxOutputTokens: modelDef.omitMaxOutputTokens,
670
713
  headers: mergeCustomModelHeaders(providerHeaders, modelDef.headers, authHeader, providerApiKey),
671
714
  compat: mergeCompat(providerCompat, modelDef.compat),
672
715
  contextPromotionTarget: modelDef.contextPromotionTarget,
@@ -823,6 +866,7 @@ function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuil
823
866
  resolvedModel.contextWindow ?? reference?.contextWindow ?? (options.useDefaults ? 128000 : undefined),
824
867
  maxTokens: resolvedModel.maxTokens ?? reference?.maxTokens ?? (options.useDefaults ? 16384 : undefined),
825
868
  headers: resolvedModel.headers,
869
+ omitMaxOutputTokens: resolvedModel.omitMaxOutputTokens ?? reference?.omitMaxOutputTokens,
826
870
  compat: mergeCompat(reference?.compat, resolvedModel.compat),
827
871
  contextPromotionTarget: resolvedModel.contextPromotionTarget,
828
872
  premiumMultiplier: resolvedModel.premiumMultiplier,
@@ -1124,6 +1168,7 @@ export class ModelRegistry {
1124
1168
  cost: customModel.cost ?? existingModel.cost,
1125
1169
  contextWindow: customModel.contextWindow ?? existingModel.contextWindow,
1126
1170
  maxTokens: customModel.maxTokens ?? existingModel.maxTokens,
1171
+ omitMaxOutputTokens: customModel.omitMaxOutputTokens ?? existingModel.omitMaxOutputTokens,
1127
1172
  // Same-id custom definitions replace bundled transport behavior. Provider-level
1128
1173
  // headers/compat were already folded into customModel during parsing; do not
1129
1174
  // re-merge bundled transport metadata here.
@@ -1214,7 +1259,18 @@ export class ModelRegistry {
1214
1259
  return models;
1215
1260
  }
1216
1261
 
1217
- return models.map(model => (model.api === "openai-completions" ? { ...model, api: "openai-responses" } : model));
1262
+ const contextLengthOverride = getOllamaContextLengthOverride();
1263
+ return models.map(model => {
1264
+ const normalized = model.api === "openai-completions" ? { ...model, api: "openai-responses" as const } : model;
1265
+ if (contextLengthOverride === undefined) {
1266
+ return normalized;
1267
+ }
1268
+ return {
1269
+ ...normalized,
1270
+ contextWindow: contextLengthOverride,
1271
+ maxTokens: Math.min(contextLengthOverride, DISCOVERY_DEFAULT_MAX_TOKENS),
1272
+ };
1273
+ });
1218
1274
  }
1219
1275
 
1220
1276
  #addImplicitDiscoverableProviders(configuredProviders: Set<string>): void {
@@ -1223,7 +1279,7 @@ export class ModelRegistry {
1223
1279
  this.#discoverableProviders.push({
1224
1280
  provider: "ollama",
1225
1281
  api: "openai-responses",
1226
- baseUrl: Bun.env.OLLAMA_BASE_URL || "http://127.0.0.1:11434",
1282
+ baseUrl: getImplicitOllamaBaseUrl(),
1227
1283
  discovery: { type: "ollama" },
1228
1284
  optional: true,
1229
1285
  });
@@ -1987,12 +2043,12 @@ export class ModelRegistry {
1987
2043
  }
1988
2044
  }
1989
2045
  #normalizeOllamaBaseUrl(baseUrl?: string): string {
1990
- const raw = baseUrl || "http://127.0.0.1:11434";
2046
+ const raw = baseUrl || DEFAULT_OLLAMA_BASE_URL;
1991
2047
  try {
1992
2048
  const parsed = new URL(raw);
1993
2049
  return `${parsed.protocol}//${parsed.host}`;
1994
2050
  } catch {
1995
- return "http://127.0.0.1:11434";
2051
+ return DEFAULT_OLLAMA_BASE_URL;
1996
2052
  }
1997
2053
  }
1998
2054
 
@@ -93,6 +93,7 @@ const ModelDefinitionSchema = z.object({
93
93
  premiumMultiplier: z.number().optional(),
94
94
  contextWindow: z.number().optional(),
95
95
  maxTokens: z.number().optional(),
96
+ omitMaxOutputTokens: z.boolean().optional(),
96
97
  headers: z.record(z.string(), z.string()).optional(),
97
98
  compat: OpenAICompatSchema.optional(),
98
99
  contextPromotionTarget: z.string().min(1).optional(),
@@ -114,6 +115,7 @@ export const ModelOverrideSchema = z.object({
114
115
  premiumMultiplier: z.number().optional(),
115
116
  contextWindow: z.number().optional(),
116
117
  maxTokens: z.number().optional(),
118
+ omitMaxOutputTokens: z.boolean().optional(),
117
119
  headers: z.record(z.string(), z.string()).optional(),
118
120
  compat: OpenAICompatSchema.optional(),
119
121
  contextPromotionTarget: z.string().min(1).optional(),
@@ -635,7 +635,7 @@ export const SETTINGS_SCHEMA = {
635
635
  tab: "appearance",
636
636
  label: "Terminal Hyperlinks",
637
637
  description:
638
- "Wrap file paths in OSC 8 hyperlinks for terminal-native click-to-open (auto: detect support; off: never; always: unconditional)",
638
+ "Wrap paths and URLs in OSC 8 hyperlinks for terminal-native click-to-open (auto: detect support; off: never; always: unconditional)",
639
639
  },
640
640
  },
641
641
  // Display rendering
@@ -722,6 +722,16 @@ export const SETTINGS_SCHEMA = {
722
722
  },
723
723
  },
724
724
 
725
+ includeModelInPrompt: {
726
+ type: "boolean",
727
+ default: true,
728
+ ui: {
729
+ tab: "model",
730
+ label: "Include Model In Prompt",
731
+ description: "Surface the active model identifier in the system prompt so the agent knows which model it is",
732
+ },
733
+ },
734
+
725
735
  // Sampling
726
736
  temperature: {
727
737
  type: "number",
@@ -2483,13 +2493,13 @@ export const SETTINGS_SCHEMA = {
2483
2493
  // Tool Discovery
2484
2494
  "tools.discoveryMode": {
2485
2495
  type: "enum",
2486
- values: ["off", "mcp-only", "all"] as const,
2487
- default: "off",
2496
+ values: ["auto", "off", "mcp-only", "all"] as const,
2497
+ default: "auto",
2488
2498
  ui: {
2489
2499
  tab: "tools",
2490
2500
  label: "Tool Discovery",
2491
2501
  description:
2492
- "Hide tools behind a search tool to save tokens. 'mcp-only' hides MCP tools; 'all' hides all non-essential built-ins too.",
2502
+ "Hide tools behind a search tool to save tokens. 'auto' hides MCP tools once the tool set has more than 40 tools; 'mcp-only' always hides MCP tools; 'all' hides all non-essential built-ins too.",
2493
2503
  },
2494
2504
  },
2495
2505
 
@@ -907,6 +907,9 @@ const SETTING_HOOKS: Partial<Record<SettingPath, SettingHook<any>>> = {
907
907
  for (const cb of appendOnlyModeCallbacks) cb(value);
908
908
  }
909
909
  },
910
+ "hindsight.bankId": () => fireHindsightScopeChanged(),
911
+ "hindsight.bankIdPrefix": () => fireHindsightScopeChanged(),
912
+ "hindsight.scoping": () => fireHindsightScopeChanged(),
910
913
  };
911
914
  /** Callbacks invoked when `provider.appendOnlyContext` changes at runtime. */
912
915
  const appendOnlyModeCallbacks = new Set<(value: string) => void>();
@@ -923,6 +926,41 @@ export function onAppendOnlyModeChanged(cb: (value: string) => void): () => void
923
926
  };
924
927
  }
925
928
 
929
+ /** Callbacks fired when any `hindsight.bankId` / `bankIdPrefix` / `scoping` value changes. */
930
+ const hindsightScopeCallbacks = new Set<() => void>();
931
+
932
+ function fireHindsightScopeChanged(): void {
933
+ // Snapshot the callback set before invoking — a callback's body is allowed
934
+ // to subscribe a NEW callback (the Hindsight backend re-registers the
935
+ // fresh state's listener on every rebuild). Iterating the live Set would
936
+ // re-invoke those just-added callbacks within the same fire, which spins
937
+ // in place: subscribe → invoke → subscribe → invoke → …
938
+ for (const cb of [...hindsightScopeCallbacks]) {
939
+ try {
940
+ cb();
941
+ } catch (err) {
942
+ logger.warn("Settings: hindsight scope hook failed", { error: String(err) });
943
+ }
944
+ }
945
+ }
946
+
947
+ /**
948
+ * Subscribe to changes in the Hindsight bank-scoping settings. Lets the
949
+ * Hindsight backend rebuild the active `HindsightSessionState` when the
950
+ * operator switches `hindsight.bankId`, `hindsight.bankIdPrefix`, or
951
+ * `hindsight.scoping` mid-session so subsequent retain/recall calls land in
952
+ * the new bank instead of the one selected at session start.
953
+ *
954
+ * Returns an unsubscribe function. The callback receives no arguments — the
955
+ * caller is expected to re-read the relevant settings via `Settings.get`.
956
+ */
957
+ export function onHindsightScopeChanged(cb: () => void): () => void {
958
+ hindsightScopeCallbacks.add(cb);
959
+ return () => {
960
+ hindsightScopeCallbacks.delete(cb);
961
+ };
962
+ }
963
+
926
964
  // ═══════════════════════════════════════════════════════════════════════════
927
965
  // Global Singleton
928
966
  // ═══════════════════════════════════════════════════════════════════════════
@@ -46,5 +46,6 @@ const doubled = value * 2;
46
46
  - Callback identity matters.
47
47
  - Type guard preserves narrowing.
48
48
  - Public API, test seam, or DI boundary needs indirection.
49
+ - Names a non-obvious formula or magic-constant computation that the inlined expression would not explain on its own.
49
50
 
50
51
  If none apply, inline it.
@@ -10,6 +10,7 @@
10
10
  * Capabilities:
11
11
  * - context-files: copilot-instructions.md in .github/
12
12
  * - instructions: *.instructions.md in .github/instructions/ with applyTo frontmatter
13
+ * - skills: <name>/SKILL.md in .github/skills/ (GitHub Agent Skills layout)
13
14
  */
14
15
  import * as path from "node:path";
15
16
  import { parseFrontmatter } from "@oh-my-pi/pi-utils";
@@ -17,9 +18,10 @@ import { registerProvider } from "../capability";
17
18
  import { type ContextFile, contextFileCapability } from "../capability/context-file";
18
19
  import { readFile } from "../capability/fs";
19
20
  import { type Instruction, instructionCapability } from "../capability/instruction";
21
+ import { type Skill, skillCapability } from "../capability/skill";
20
22
  import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
21
23
 
22
- import { calculateDepth, createSourceMeta, getProjectPath, loadFilesFromDir } from "./helpers";
24
+ import { calculateDepth, createSourceMeta, getProjectPath, loadFilesFromDir, scanSkillsFromDir } from "./helpers";
23
25
 
24
26
  const PROVIDER_ID = "github";
25
27
  const DISPLAY_NAME = "GitHub Copilot";
@@ -97,6 +99,32 @@ function transformInstruction(name: string, content: string, filePath: string, s
97
99
  };
98
100
  }
99
101
 
102
+ // =============================================================================
103
+ // Skills
104
+ // =============================================================================
105
+
106
+ /**
107
+ * Load skills from `.github/skills/<name>/SKILL.md`.
108
+ *
109
+ * GitHub documents this layout for Copilot Agent Skills and matches the
110
+ * non-recursive shape `scanSkillsFromDir` already expects. `requireDescription`
111
+ * is on to match the Agent Skills spec (name + description are mandatory) and
112
+ * the sibling `native`/`omp-plugins` providers.
113
+ *
114
+ * @see https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/customize-cloud-agent/add-skills
115
+ */
116
+ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
117
+ const skillsDir = getProjectPath(ctx, "github", "skills");
118
+ if (!skillsDir) return { items: [], warnings: [] };
119
+
120
+ return scanSkillsFromDir(ctx, {
121
+ dir: skillsDir,
122
+ providerId: PROVIDER_ID,
123
+ level: "project",
124
+ requireDescription: true,
125
+ });
126
+ }
127
+
100
128
  // =============================================================================
101
129
  // Provider Registration
102
130
  // =============================================================================
@@ -116,3 +144,11 @@ registerProvider(instructionCapability.id, {
116
144
  priority: PRIORITY,
117
145
  load: loadInstructions,
118
146
  });
147
+
148
+ registerProvider<Skill>(skillCapability.id, {
149
+ id: PROVIDER_ID,
150
+ displayName: DISPLAY_NAME,
151
+ description: "Load skills from .github/skills/*/SKILL.md",
152
+ priority: PRIORITY,
153
+ load: loadSkills,
154
+ });
@@ -212,6 +212,7 @@ export interface ParsedAgentFields {
212
212
  output?: unknown;
213
213
  thinkingLevel?: ThinkingLevel;
214
214
  autoloadSkills?: string[];
215
+ readSummarize?: boolean;
215
216
  blocking?: boolean;
216
217
  }
217
218
 
@@ -265,10 +266,11 @@ export function parseAgentFields(frontmatter: Record<string, unknown>): ParsedAg
265
266
  const thinkingLevel = parseThinkingLevel(rawThinkingLevel);
266
267
  const model = parseModelList(frontmatter.model);
267
268
  const blocking = parseBoolean(frontmatter.blocking);
269
+ const readSummarize = parseBoolean(frontmatter.readSummarize);
268
270
  const autoloadSkills = parseArrayOrCSV(frontmatter.autoloadSkills)
269
271
  ?.map(s => s.trim())
270
272
  .filter(Boolean);
271
- return { name, description, tools, spawns, model, output, thinkingLevel, blocking, autoloadSkills };
273
+ return { name, description, tools, spawns, model, output, thinkingLevel, blocking, autoloadSkills, readSummarize };
272
274
  }
273
275
 
274
276
  async function globIf(
@@ -397,6 +397,78 @@ describe("agent() through eval runtimes", () => {
397
397
  expect(maxInFlight).toBeLessThanOrEqual(2);
398
398
  });
399
399
 
400
+ it("interrupting a Python parallel() fan-out settles the kernel cleanly and preserves session state", async () => {
401
+ using tempDir = TempDir.createSync("@omp-eval-agent-py-interrupt-");
402
+ const settings = Settings.isolated({
403
+ "async.enabled": false,
404
+ "task.isolation.mode": "none",
405
+ "task.enableLsp": true,
406
+ "task.maxConcurrency": 6,
407
+ });
408
+ const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "py-agent-interrupt", settings);
409
+ mockAgents();
410
+ // Subagents that ignore the abort for far longer than the kernel's SIGINT
411
+ // escalation window. Each kernel worker thread blocks in a synchronous
412
+ // `urllib` bridge call, joined by `parallel()`'s ThreadPoolExecutor exit.
413
+ // The host must respond the instant the cell aborts so the kernel can
414
+ // unwind via KeyboardInterrupt instead of being hard-killed (which used to
415
+ // surface "[kernel] Python kernel shutdown" and lose all session state).
416
+ vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => {
417
+ await Bun.sleep(9000); // deliberately ignores options.signal
418
+ return singleResult(options, { output: options.assignment ?? "" });
419
+ });
420
+
421
+ // Seed persistent session state and confirm the kernel is reusable.
422
+ const seed = await executePython("PREP_MARKER = 4242", {
423
+ cwd: tempDir.path(),
424
+ sessionId,
425
+ sessionFile,
426
+ kernelMode: "session",
427
+ toolSession: session,
428
+ });
429
+ if (seed.exitCode === undefined && seed.cancelled) {
430
+ expect(seed.output).toBe("");
431
+ return; // kernel unavailable in this environment
432
+ }
433
+ expect(seed.exitCode).toBe(0);
434
+
435
+ const ac = new AbortController();
436
+ // Abort ~1s in, after the worker threads are blocked in their bridge calls.
437
+ setTimeout(() => ac.abort(new Error("external interrupt")), 1000);
438
+
439
+ const start = Date.now();
440
+ const result = await executePython(
441
+ "import json\nprint(json.dumps(parallel([lambda n=n: agent(str(n)) for n in range(12)])))",
442
+ {
443
+ cwd: tempDir.path(),
444
+ sessionId,
445
+ sessionFile,
446
+ kernelMode: "session",
447
+ toolSession: session,
448
+ idleTimeoutMs: 60_000,
449
+ signal: ac.signal,
450
+ },
451
+ );
452
+ const elapsed = Date.now() - start;
453
+
454
+ // Cancelled, but cleanly: no hard-kill, settled well within the kernel's 5s
455
+ // SIGINT escalation window rather than ~6s after it.
456
+ expect(result.cancelled).toBe(true);
457
+ expect(result.output).not.toContain("Python kernel shutdown");
458
+ expect(elapsed).toBeLessThan(4000);
459
+
460
+ // The persistent kernel survived the interrupt: prior state is intact.
461
+ const after = await executePython("print(PREP_MARKER)", {
462
+ cwd: tempDir.path(),
463
+ sessionId,
464
+ sessionFile,
465
+ kernelMode: "session",
466
+ toolSession: session,
467
+ });
468
+ expect(after.exitCode).toBe(0);
469
+ expect(after.output.trim()).toBe("4242");
470
+ }, 30_000);
471
+
400
472
  it("streams enriched agent progress through onStatus before the cell finishes", async () => {
401
473
  using tempDir = TempDir.createSync("@omp-eval-agent-progress-");
402
474
  const { session, sessionFile, sessionId } = makeEvalSession(tempDir, "js-agent-progress");
@@ -30,6 +30,48 @@ interface BridgeServer {
30
30
  const registrations = new Map<string, PyToolBridgeEntry>();
31
31
  let serverPromise: Promise<BridgeServer> | null = null;
32
32
 
33
+ /**
34
+ * Forward a bridge call to {@link callSessionTool}, but resolve the HTTP request
35
+ * the instant the cell's signal aborts instead of waiting for the tool/subagent
36
+ * to fully tear down.
37
+ *
38
+ * The kernel invokes this bridge with a *blocking* `urllib` request from a
39
+ * worker thread (each `agent()` / `tool.*` call). When the cell is interrupted,
40
+ * `parallel()`'s `ThreadPoolExecutor.__exit__` joins those worker threads
41
+ * (`shutdown(wait=True)`), so they cannot unwind until their `urllib` call
42
+ * returns — i.e. until this handler responds. A host-side `agent()` teardown
43
+ * (aborting nested LLM streams + tools across a wide fan-out) routinely exceeds
44
+ * the kernel's SIGINT escalation window, so the kernel was hard-killed and its
45
+ * persistent state lost while the subagents were still winding down. Responding
46
+ * immediately on abort lets the kernel raise through the blocked call and settle
47
+ * cleanly (preserving state); the already-signaled call keeps tearing down in
48
+ * the background, its eventual result/rejection swallowed.
49
+ */
50
+ async function callSessionToolPromptOnAbort(name: string, args: unknown, entry: PyToolBridgeEntry): Promise<unknown> {
51
+ const call = callSessionTool(name, args, {
52
+ session: entry.toolSession,
53
+ signal: entry.signal,
54
+ emitStatus: entry.emitStatus,
55
+ });
56
+ const signal = entry.signal;
57
+ if (!signal) return await call;
58
+ if (signal.aborted) {
59
+ void call.catch(() => {});
60
+ throw new Error(`bridge call ${JSON.stringify(name)} aborted: eval cell was interrupted`);
61
+ }
62
+ const { promise: aborted, reject } = Promise.withResolvers<never>();
63
+ const onAbort = () => reject(new Error(`bridge call ${JSON.stringify(name)} aborted: eval cell was interrupted`));
64
+ signal.addEventListener("abort", onAbort, { once: true });
65
+ try {
66
+ return await Promise.race([call, aborted]);
67
+ } finally {
68
+ signal.removeEventListener("abort", onAbort);
69
+ // `call` may still be settling (subagent teardown after its own abort);
70
+ // swallow its outcome so an abort-won race can't surface as unhandled.
71
+ void call.catch(() => {});
72
+ }
73
+ }
74
+
33
75
  async function startServer(): Promise<BridgeServer> {
34
76
  const token = crypto.randomUUID();
35
77
  const server = Bun.serve({
@@ -66,11 +108,7 @@ async function startServer(): Promise<BridgeServer> {
66
108
  }
67
109
 
68
110
  try {
69
- const value = await callSessionTool(name, body.args, {
70
- session: entry.toolSession,
71
- signal: entry.signal,
72
- emitStatus: entry.emitStatus,
73
- });
111
+ const value = await callSessionToolPromptOnAbort(name, body.args, entry);
74
112
  return Response.json({ ok: true, value });
75
113
  } catch (err) {
76
114
  return Response.json({
@@ -12,6 +12,35 @@ async function getHeadTag(api: CustomCommandAPI): Promise<string | undefined> {
12
12
  }
13
13
  }
14
14
 
15
+ async function getCurrentBranch(api: CustomCommandAPI): Promise<string> {
16
+ try {
17
+ return (await git.branch.current(api.cwd)) ?? "HEAD";
18
+ } catch {
19
+ return "HEAD";
20
+ }
21
+ }
22
+
23
+ async function getPushRemote(api: CustomCommandAPI, branch: string): Promise<string | undefined> {
24
+ try {
25
+ return (
26
+ (await git.config.getBranch(api.cwd, branch, "pushRemote")) ??
27
+ (await git.config.getBranch(api.cwd, branch, "remote"))
28
+ );
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ }
33
+
34
+ async function getHeadTagContext(api: CustomCommandAPI): Promise<{ branch: string; headTag?: string; remote: string }> {
35
+ const branch = await getCurrentBranch(api);
36
+ const [headTag, pushRemote] = await Promise.all([getHeadTag(api), getPushRemote(api, branch)]);
37
+ return {
38
+ headTag,
39
+ branch,
40
+ remote: pushRemote ?? "origin",
41
+ };
42
+ }
43
+
15
44
  export class GreenCommand implements CustomCommand {
16
45
  name = "green";
17
46
  description = "Generate a prompt to iterate on CI failures until the branch is green";
@@ -19,7 +48,7 @@ export class GreenCommand implements CustomCommand {
19
48
  constructor(private api: CustomCommandAPI) {}
20
49
 
21
50
  async execute(_args: string[], _ctx: HookCommandContext): Promise<string> {
22
- const headTag = await getHeadTag(this.api);
23
- return prompt.render(ciGreenRequestTemplate, { headTag });
51
+ const { headTag, branch, remote } = await getHeadTagContext(this.api);
52
+ return prompt.render(ciGreenRequestTemplate, { headTag, branch, remote });
24
53
  }
25
54
  }