gentle-pi 0.3.8 → 0.3.10

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.
@@ -233,6 +233,9 @@ const MODEL_CONTROL_OPTIONS = [
233
233
  INHERIT_MODEL,
234
234
  CUSTOM_MODEL,
235
235
  ] as const;
236
+ const MODEL_PANEL_MAX_RENDER_ROWS = 20;
237
+ const AGENT_LIST_MAX_VISIBLE_ROWS = MODEL_PANEL_MAX_RENDER_ROWS - 13;
238
+ const MODEL_LIST_MAX_VISIBLE_ROWS = 12;
236
239
 
237
240
  function readStringPath(value: unknown, path: string[]): string | undefined {
238
241
  let current = value;
@@ -367,16 +370,32 @@ function isThinkingLevel(value: unknown): value is ThinkingLevel {
367
370
  );
368
371
  }
369
372
 
373
+ const ANSI_ESCAPE_PATTERN =
374
+ /[\u001b\u009b][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
375
+ const CONTROL_CHAR_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g;
376
+ const SAFE_MODEL_ID_PATTERN = /^[A-Za-z0-9._~:@/+%-]+$/;
377
+
378
+ function sanitizeTerminalText(value: string): string {
379
+ return value
380
+ .replace(ANSI_ESCAPE_PATTERN, "")
381
+ .replace(CONTROL_CHAR_PATTERN, "");
382
+ }
383
+
384
+ function normalizeModelId(value: unknown): string | undefined {
385
+ if (typeof value !== "string") return undefined;
386
+ const model = value.trim();
387
+ if (model.length === 0) return undefined;
388
+ if (!SAFE_MODEL_ID_PATTERN.test(model)) return undefined;
389
+ return model;
390
+ }
391
+
370
392
  function normalizeRoutingEntry(value: unknown): AgentRoutingEntry | undefined {
371
393
  if (typeof value === "string") {
372
- const model = value.trim();
373
- return model.length > 0 ? { model } : undefined;
394
+ const model = normalizeModelId(value);
395
+ return model ? { model } : undefined;
374
396
  }
375
397
  if (!isRecord(value)) return undefined;
376
- const model =
377
- typeof value.model === "string" && value.model.trim().length > 0
378
- ? value.model.trim()
379
- : undefined;
398
+ const model = normalizeModelId(value.model);
380
399
  const thinking = isThinkingLevel(value.thinking) ? value.thinking : undefined;
381
400
  if (!model && !thinking) return undefined;
382
401
  return { model, thinking };
@@ -804,14 +823,15 @@ function describeModelConfig(cwd: string, config: AgentModelConfig): string[] {
804
823
  const entry = config[agent.name];
805
824
  const model = entry?.model ?? "inherit";
806
825
  const thinking = entry?.thinking ?? "inherit";
807
- return `${agent.name}: model=${model}, effort=${thinking}`;
826
+ return `${sanitizeTerminalText(agent.name)}: model=${sanitizeTerminalText(model)}, effort=${sanitizeTerminalText(thinking)}`;
808
827
  });
809
828
  }
810
829
 
811
830
  async function getPiModelOptions(ctx: ExtensionContext): Promise<string[]> {
812
831
  const models = await ctx.modelRegistry.getAvailable();
813
832
  const modelIds = models
814
- .map((model) => `${model.provider}/${model.id}`)
833
+ .map((model) => normalizeModelId(`${model.provider}/${model.id}`))
834
+ .filter((model): model is string => model !== undefined)
815
835
  .sort((left, right) => left.localeCompare(right));
816
836
  return [...MODEL_CONTROL_OPTIONS, ...modelIds];
817
837
  }
@@ -868,9 +888,25 @@ class SddModelPanel implements OverlayComponent {
868
888
  }
869
889
 
870
890
  render(width: number): string[] {
871
- if (this.mode === "models") return this.renderModelPicker(width);
872
- if (this.mode === "effort") return this.renderEffortPicker(width);
873
- return this.renderAgentList(width);
891
+ const innerWidth = Math.max(1, width - 4);
892
+ const lines =
893
+ this.mode === "models"
894
+ ? this.renderModelPicker(innerWidth)
895
+ : this.mode === "effort"
896
+ ? this.renderEffortPicker(innerWidth)
897
+ : this.renderAgentList(innerWidth);
898
+ return this.renderCard(lines, width);
899
+ }
900
+
901
+ private renderCard(lines: string[], width: number): string[] {
902
+ const innerWidth = Math.max(1, width - 4);
903
+ const fit = (text = "") =>
904
+ truncateToWidth(sanitizeTerminalText(text), innerWidth, "…", true).padEnd(innerWidth);
905
+ return [
906
+ `╭${"─".repeat(innerWidth + 2)}╮`,
907
+ ...lines.map((line) => `│ ${fit(line)} │`),
908
+ `╰${"─".repeat(innerWidth + 2)}╯`,
909
+ ];
874
910
  }
875
911
 
876
912
  private handleAgentInput(data: string): void {
@@ -883,25 +919,33 @@ class SddModelPanel implements OverlayComponent {
883
919
  this.done({ type: "save", config: this.draft });
884
920
  return;
885
921
  }
886
- if (matchesKey(data, "down") || data === "j") {
922
+ if (matchesKey(data, "down") || matchesKey(data, "j")) {
887
923
  this.cursor = Math.min(maxCursor, this.cursor + 1);
888
924
  return;
889
925
  }
890
- if (matchesKey(data, "up") || data === "k") {
926
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
891
927
  this.cursor = Math.max(0, this.cursor - 1);
892
928
  return;
893
929
  }
894
- if (data === "i") {
930
+ if (matchesKey(data, "g")) {
931
+ this.cursor = 0;
932
+ return;
933
+ }
934
+ if (data === "G") {
935
+ this.cursor = maxCursor;
936
+ return;
937
+ }
938
+ if (matchesKey(data, "i")) {
895
939
  this.applyInherit();
896
940
  return;
897
941
  }
898
- if (data === "e") {
942
+ if (matchesKey(data, "e")) {
899
943
  this.selectedRow = this.rows[this.cursor] ?? SET_ALL_AGENTS;
900
944
  this.mode = "effort";
901
945
  this.effortCursor = 0;
902
946
  return;
903
947
  }
904
- if (data === "c") {
948
+ if (matchesKey(data, "c")) {
905
949
  const row = this.rows[this.cursor];
906
950
  if (row === SET_ALL_AGENTS)
907
951
  this.done({ type: "custom", agent: "all", config: this.draft });
@@ -943,14 +987,14 @@ class SddModelPanel implements OverlayComponent {
943
987
  );
944
988
  return;
945
989
  }
946
- if (matchesKey(data, "down") || data === "j") {
990
+ if (matchesKey(data, "down") || matchesKey(data, "j")) {
947
991
  this.modelCursor = Math.min(
948
992
  Math.max(0, options.length - 1),
949
993
  this.modelCursor + 1,
950
994
  );
951
995
  return;
952
996
  }
953
- if (matchesKey(data, "up") || data === "k") {
997
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
954
998
  this.modelCursor = Math.max(0, this.modelCursor - 1);
955
999
  return;
956
1000
  }
@@ -1041,11 +1085,22 @@ class SddModelPanel implements OverlayComponent {
1041
1085
  const lines: string[] = [];
1042
1086
  const line = (text = "") =>
1043
1087
  truncateToWidth(text, Math.max(1, width), "…", true);
1044
- lines.push(line("Assign Models to Agents"));
1088
+ lines.push(line("Assign Models and Effort to Agents"));
1045
1089
  lines.push("");
1046
1090
  lines.push(line("Current assignments:"));
1047
1091
  lines.push("");
1048
- for (let i = 0; i < this.rows.length; i++) {
1092
+ const visibleRows = Math.min(AGENT_LIST_MAX_VISIBLE_ROWS, this.rows.length);
1093
+ const listCursor = Math.min(this.cursor, this.rows.length - 1);
1094
+ const start = Math.max(
1095
+ 0,
1096
+ Math.min(
1097
+ listCursor - Math.floor(visibleRows / 2),
1098
+ Math.max(0, this.rows.length - visibleRows),
1099
+ ),
1100
+ );
1101
+ const end = Math.min(this.rows.length, start + visibleRows);
1102
+ if (start > 0) lines.push(line(` ↑ ${start} more agent(s)`));
1103
+ for (let i = start; i < end; i++) {
1049
1104
  const row = this.rows[i] ?? SET_ALL_AGENTS;
1050
1105
  const focused = i === this.cursor;
1051
1106
  const label =
@@ -1054,6 +1109,8 @@ class SddModelPanel implements OverlayComponent {
1054
1109
  : this.renderAgentLabel(row);
1055
1110
  lines.push(line(`${focused ? "▸" : " "} ${label}`));
1056
1111
  }
1112
+ if (end < this.rows.length)
1113
+ lines.push(line(` ↓ ${this.rows.length - end} more agent(s)`));
1057
1114
  lines.push("");
1058
1115
  lines.push(
1059
1116
  line(`${this.cursor === this.rows.length ? "▸" : " "} Continue`),
@@ -1064,7 +1121,7 @@ class SddModelPanel implements OverlayComponent {
1064
1121
  lines.push("");
1065
1122
  lines.push(
1066
1123
  line(
1067
- "j/k: navigate • enter: change model / confirm • e: change effort • i: inherit all • c: custom model • ctrl+s: save • esc: back",
1124
+ "j/k: scrollg/G: top/bottom • enter: change model / confirm • e: effort • i: inherit • c: custom • ctrl+s: save • esc: back",
1068
1125
  ),
1069
1126
  );
1070
1127
  return lines;
@@ -1075,22 +1132,21 @@ class SddModelPanel implements OverlayComponent {
1075
1132
  const options = this.filteredModelOptions();
1076
1133
  const line = (text = "") =>
1077
1134
  truncateToWidth(text, Math.max(1, width), "…", true);
1078
- lines.push(line(`Select model for ${this.selectedRow}`));
1135
+ lines.push(line(`Select model for ${sanitizeTerminalText(this.selectedRow)}`));
1079
1136
  lines.push("");
1080
1137
  lines.push(line(`◎ ${this.query || "search..."}`));
1081
1138
  lines.push("");
1082
- const maxVisible = 12;
1083
1139
  const start = Math.max(
1084
1140
  0,
1085
1141
  Math.min(
1086
- this.modelCursor - Math.floor(maxVisible / 2),
1087
- Math.max(0, options.length - maxVisible),
1142
+ this.modelCursor - Math.floor(MODEL_LIST_MAX_VISIBLE_ROWS / 2),
1143
+ Math.max(0, options.length - MODEL_LIST_MAX_VISIBLE_ROWS),
1088
1144
  ),
1089
1145
  );
1090
- const end = Math.min(options.length, start + maxVisible);
1146
+ const end = Math.min(options.length, start + MODEL_LIST_MAX_VISIBLE_ROWS);
1091
1147
  for (let i = start; i < end; i++) {
1092
1148
  const focused = i === this.modelCursor;
1093
- lines.push(line(`${focused ? "▸" : " "} ${options[i]}`));
1149
+ lines.push(line(`${focused ? "▸" : " "} ${sanitizeTerminalText(options[i] ?? "")}`));
1094
1150
  }
1095
1151
  if (options.length === 0) lines.push(line(" No matching models"));
1096
1152
  lines.push("");
@@ -1109,14 +1165,14 @@ class SddModelPanel implements OverlayComponent {
1109
1165
  this.mode = "agents";
1110
1166
  return;
1111
1167
  }
1112
- if (matchesKey(data, "down") || data === "j") {
1168
+ if (matchesKey(data, "down") || matchesKey(data, "j")) {
1113
1169
  this.effortCursor = Math.min(
1114
1170
  Math.max(0, THINKING_OPTIONS.length - 1),
1115
1171
  this.effortCursor + 1,
1116
1172
  );
1117
1173
  return;
1118
1174
  }
1119
- if (matchesKey(data, "up") || data === "k") {
1175
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
1120
1176
  this.effortCursor = Math.max(0, this.effortCursor - 1);
1121
1177
  return;
1122
1178
  }
@@ -1131,7 +1187,7 @@ class SddModelPanel implements OverlayComponent {
1131
1187
  const lines: string[] = [];
1132
1188
  const line = (text = "") =>
1133
1189
  truncateToWidth(text, Math.max(1, width), "…", true);
1134
- lines.push(line(`Select effort for ${this.selectedRow}`));
1190
+ lines.push(line(`Select effort for ${sanitizeTerminalText(this.selectedRow)}`));
1135
1191
  lines.push("");
1136
1192
  for (let i = 0; i < THINKING_OPTIONS.length; i++) {
1137
1193
  const focused = i === this.effortCursor;
@@ -1157,13 +1213,13 @@ class SddModelPanel implements OverlayComponent {
1157
1213
  const effortLabel = efforts.every((value) => value === firstEffort)
1158
1214
  ? firstEffort
1159
1215
  : "mixed";
1160
- return `${row.padEnd(20)} model=${modelLabel}, effort=${effortLabel}`;
1216
+ return `${sanitizeTerminalText(row).padEnd(20)} model=${sanitizeTerminalText(modelLabel)}, effort=${sanitizeTerminalText(effortLabel)}`;
1161
1217
  }
1162
1218
 
1163
1219
  private renderAgentLabel(row: string): string {
1164
1220
  const model = this.draft[row]?.model ?? "inherit";
1165
1221
  const effort = this.draft[row]?.thinking ?? "inherit";
1166
- return `${row.padEnd(20)} model=${model}, effort=${effort}`;
1222
+ return `${sanitizeTerminalText(row).padEnd(20)} model=${sanitizeTerminalText(model)}, effort=${sanitizeTerminalText(effort)}`;
1167
1223
  }
1168
1224
  }
1169
1225
 
@@ -1206,18 +1262,27 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
1206
1262
  ? "inherit"
1207
1263
  : (config[result.agent]?.model ?? "inherit");
1208
1264
  const custom = await ctx.ui.input(
1209
- `${result.agent === "all" ? "all agents" : result.agent} custom model id`,
1210
- current === "inherit" ? "provider/model" : current,
1265
+ `${result.agent === "all" ? "all agents" : sanitizeTerminalText(result.agent)} custom model id`,
1266
+ current === "inherit" ? "provider/model" : sanitizeTerminalText(current),
1211
1267
  );
1212
1268
  if (custom === undefined) return;
1213
1269
  const trimmed = custom.trim();
1214
1270
  if (trimmed.length > 0) {
1271
+ const model = normalizeModelId(trimmed);
1272
+ if (!model) {
1273
+ ctx.ui.notify(
1274
+ "Custom model id must be a single-line provider/model identifier using letters, numbers, '.', '-', '_', '~', ':', '@', '/', '+', '%' only.",
1275
+ "warning",
1276
+ );
1277
+ result = await showSddModelPanel(ctx, config);
1278
+ continue;
1279
+ }
1215
1280
  if (result.agent === "all") {
1216
1281
  const next: AgentModelConfig = { ...config };
1217
1282
  for (const agent of listDiscoverableAgents(ctx.cwd)) {
1218
1283
  next[agent.name] = {
1219
1284
  ...(next[agent.name] ?? {}),
1220
- model: trimmed,
1285
+ model,
1221
1286
  };
1222
1287
  }
1223
1288
  config = next;
@@ -1226,7 +1291,7 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
1226
1291
  ...config,
1227
1292
  [result.agent]: {
1228
1293
  ...(config[result.agent] ?? {}),
1229
- model: trimmed,
1294
+ model,
1230
1295
  },
1231
1296
  };
1232
1297
  }
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { watch } from "node:fs";
2
+ import { existsSync, watch } from "node:fs";
3
3
  import {
4
4
  access,
5
5
  mkdir,
@@ -11,6 +11,7 @@ import {
11
11
  } from "node:fs/promises";
12
12
  import { homedir } from "node:os";
13
13
  import { basename, join, normalize, relative, sep } from "node:path";
14
+ import { fileURLToPath } from "node:url";
14
15
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
16
 
16
17
  const REGISTRY_REL_PATH = ".atl/skill-registry.md";
@@ -26,6 +27,13 @@ const NO_SKILL_REGISTRY_ENV = "GENTLE_PI_NO_SKILL_REGISTRY";
26
27
  const LEGACY_PROJECT_REGISTRY_REL_PATH = ".pi/extensions/skill-registry.ts";
27
28
  const LEGACY_PROJECT_REGISTRY_DISABLED_REL_PATH =
28
29
  ".pi/extensions/skill-registry.ts.disabled";
30
+ const SKILL_REGISTRY_EXTENSION_SOURCE_KEY =
31
+ "__gentlePiSkillRegistryExtensionSource";
32
+
33
+ interface SkillRegistryExtensionGlobal {
34
+ [SKILL_REGISTRY_EXTENSION_SOURCE_KEY]?: string;
35
+ }
36
+
29
37
  async function pathExists(path: string): Promise<boolean> {
30
38
  try {
31
39
  await access(path);
@@ -415,6 +423,40 @@ function shouldSkipSkillRegistryStartup(
415
423
  );
416
424
  }
417
425
 
426
+ function normalizeExtensionSource(source: string): string {
427
+ return source.split(/[?#]/, 1)[0];
428
+ }
429
+
430
+ function extensionSourcePath(source: string): string | undefined {
431
+ const cleanSource = normalizeExtensionSource(source);
432
+ if (!cleanSource.startsWith("file:")) return undefined;
433
+ try {
434
+ return comparablePath(fileURLToPath(cleanSource));
435
+ } catch {
436
+ return undefined;
437
+ }
438
+ }
439
+
440
+ function shouldSkipDuplicateExtensionLoad(
441
+ source = import.meta.url,
442
+ cwd = process.cwd(),
443
+ state = globalThis as typeof globalThis & SkillRegistryExtensionGlobal,
444
+ ): boolean {
445
+ const currentPath = extensionSourcePath(source);
446
+ const projectLocalPath = comparablePath(join(cwd, "extensions", "skill-registry.ts"));
447
+ if (currentPath && currentPath !== projectLocalPath && existsSync(projectLocalPath)) {
448
+ return true;
449
+ }
450
+
451
+ const currentSource = currentPath ?? normalizeExtensionSource(source);
452
+ const existingSource = state[SKILL_REGISTRY_EXTENSION_SOURCE_KEY];
453
+ if (!existingSource) {
454
+ state[SKILL_REGISTRY_EXTENSION_SOURCE_KEY] = currentSource;
455
+ return false;
456
+ }
457
+ return existingSource !== currentSource;
458
+ }
459
+
418
460
  async function startSkillRegistryWatcher(
419
461
  cwd: string,
420
462
  notify: (message: string) => void,
@@ -460,9 +502,12 @@ export const __testing = {
460
502
  parseFrontmatter,
461
503
  renderRegistry,
462
504
  shouldSkipSkillRegistryStartup,
505
+ shouldSkipDuplicateExtensionLoad,
463
506
  };
464
507
 
465
508
  export default function (pi: ExtensionAPI) {
509
+ if (shouldSkipDuplicateExtensionLoad()) return;
510
+
466
511
  pi.registerFlag(NO_SKILL_REGISTRY_FLAG, {
467
512
  description: "Skip the Gentle AI skill registry refresh and watcher on startup.",
468
513
  type: "boolean",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -5,6 +5,7 @@ import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promis
5
5
  import { tmpdir } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
7
  import { discoverAndLoadExtensions } from "@earendil-works/pi-coding-agent";
8
+ import { matchesKey } from "@earendil-works/pi-tui";
8
9
  import { fileURLToPath, pathToFileURL } from "node:url";
9
10
 
10
11
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -640,12 +641,75 @@ async function run() {
640
641
  join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
641
642
  `---\nname: sdd-apply\ndescription: Apply phase\n---\n\nbody\n`,
642
643
  );
644
+ for (let i = 0; i < 25; i++) {
645
+ const name = `large-agent-${String(i).padStart(2, "0")}`;
646
+ await writeFile(
647
+ join(modelsCwd, ".pi", "agents", `${name}.md`),
648
+ `---\nname: ${name}\ndescription: Scroll fixture\n---\n`,
649
+ );
650
+ }
651
+ await writeFile(
652
+ join(modelsCwd, ".pi", "agents", "escape-agent.md"),
653
+ `---\nname: evil\u001b]52;c;Zm9v\u0007-agent\ndescription: Escape fixture\n---\n`,
654
+ );
643
655
  await writeFile(
644
656
  globalModelsPath,
645
657
  JSON.stringify({ "sdd-apply": "openai/gpt-5" }, null, 2),
646
658
  );
647
659
 
648
660
  const ctx = createCtx(modelsCwd, true);
661
+ ctx.modelRegistry.getAvailable = async () => [
662
+ { provider: "safe", id: "model" },
663
+ { provider: "evil\u001b]52;c;Zm9v\u0007", id: "model" },
664
+ ];
665
+ ctx.ui.custom = (factory) => {
666
+ const panel = factory(null, null, null, () => undefined);
667
+ const initialLines = panel.render(120);
668
+ assert.ok(
669
+ initialLines[0].startsWith("╭") && initialLines.at(-1).startsWith("╰"),
670
+ "model panel should render inside a bordered card",
671
+ );
672
+ assert.ok(
673
+ initialLines.length <= 20,
674
+ "long model agent list should fit within a 24-row terminal 85% overlay budget",
675
+ );
676
+ assert.ok(
677
+ initialLines.some((line) => /↓ \d+ more agent\(s\)/.test(line)),
678
+ "long model agent list should render a down-scroll indicator",
679
+ );
680
+ assert.ok(
681
+ initialLines.some((line) => line.includes("Continue")),
682
+ "long model agent list should keep Continue visible",
683
+ );
684
+ assert.doesNotMatch(
685
+ initialLines.join("\n"),
686
+ /[\u001b\u0007]/,
687
+ "model panel must strip terminal control sequences from agent labels",
688
+ );
689
+ for (let i = 0; i < 20; i++) panel.handleInput("j");
690
+ const scrolledLines = panel.render(120);
691
+ assert.ok(
692
+ scrolledLines.length <= 20,
693
+ "scrolled model agent list should stay within the overlay height budget",
694
+ );
695
+ assert.ok(
696
+ scrolledLines.some((line) => /↑ \d+ more agent\(s\)/.test(line)),
697
+ "long model agent list should render an up-scroll indicator after navigation",
698
+ );
699
+ panel.handleInput("G");
700
+ const bottomLines = panel.render(120);
701
+ assert.ok(
702
+ bottomLines.length <= 20,
703
+ "bottom model agent list should stay within the overlay height budget",
704
+ );
705
+ assert.ok(
706
+ bottomLines.some((line) => line.includes("▸ ← Back")),
707
+ "G should jump to the Back action",
708
+ );
709
+ return Promise.resolve({ type: "cancel" });
710
+ };
711
+ await commands.get("gentle:models").handler("", ctx);
712
+
649
713
  await hooks.get("session_start")[0]({ reason: "startup" }, ctx);
650
714
  const legacyAppliedAgent = await readFile(
651
715
  join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
@@ -663,6 +727,11 @@ async function run() {
663
727
  },
664
728
  });
665
729
  await commands.get("gentle:models").handler("", ctx);
730
+ assert.doesNotMatch(
731
+ ctx.ui.notifications.at(-1).message,
732
+ /[\u001b\u0007]/,
733
+ "model save notification must strip terminal control sequences from discovered agent names",
734
+ );
666
735
 
667
736
  const savedConfig = JSON.parse(
668
737
  await readFile(globalModelsPath, "utf8"),
@@ -693,6 +762,10 @@ async function run() {
693
762
  );
694
763
  assert.equal(settings.subagents.agentOverrides.worker.thinking, "low");
695
764
 
765
+ const kittyE = "\x1b[101u";
766
+ assert.notEqual(kittyE, "e");
767
+ assert.equal(matchesKey(kittyE, "e"), true);
768
+
696
769
  let customPanelCalls = 0;
697
770
  ctx.ui.input = async () => "custom/provider-model";
698
771
  ctx.ui.custom = (factory) =>
@@ -700,7 +773,7 @@ async function run() {
700
773
  customPanelCalls += 1;
701
774
  const panel = factory(null, null, null, resolve);
702
775
  if (customPanelCalls === 1) {
703
- panel.handleInput("e"); // effort picker for all agents
776
+ panel.handleInput(kittyE); // effort picker for all agents
704
777
  for (let i = 0; i < 4; i++) panel.handleInput("j"); // medium
705
778
  panel.handleInput("\r");
706
779
  panel.handleInput("c"); // custom model from the same unsaved draft
@@ -717,6 +790,31 @@ async function run() {
717
790
  model: "custom/provider-model",
718
791
  thinking: "medium",
719
792
  });
793
+
794
+ let invalidCustomCalls = 0;
795
+ ctx.ui.input = async () => "bad\nmodel: injected";
796
+ ctx.ui.custom = (factory) =>
797
+ new Promise((resolve) => {
798
+ invalidCustomCalls += 1;
799
+ const panel = factory(null, null, null, resolve);
800
+ if (invalidCustomCalls === 1) {
801
+ panel.handleInput("c");
802
+ return;
803
+ }
804
+ panel.handleInput("\u001b");
805
+ });
806
+ await commands.get("gentle:models").handler("", ctx);
807
+ assert.match(
808
+ ctx.ui.notifications.at(-1).message,
809
+ /Custom model id must be a single-line/,
810
+ );
811
+ const rejectedCustomConfig = JSON.parse(
812
+ await readFile(globalModelsPath, "utf8"),
813
+ );
814
+ assert.deepEqual(rejectedCustomConfig["sdd-apply"], {
815
+ model: "custom/provider-model",
816
+ thinking: "medium",
817
+ });
720
818
  } finally {
721
819
  await rm(modelsCwd, { recursive: true, force: true });
722
820
  await rm(globalModelsPath, { force: true });
@@ -3,6 +3,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import test from "node:test";
6
+ import { pathToFileURL } from "node:url";
6
7
  import { __testing } from "../extensions/skill-registry.ts";
7
8
 
8
9
  test("project skill dirs include supported workspace roots", () => {
@@ -115,6 +116,43 @@ test("startup skip honors no skill registry controls", () => {
115
116
  assert.equal(__testing.shouldSkipSkillRegistryStartup(disabled, [], {}), false);
116
117
  });
117
118
 
119
+ test("duplicate extension load is skipped only across different sources", () => {
120
+ const state = {};
121
+
122
+ assert.equal(
123
+ __testing.shouldSkipDuplicateExtensionLoad("file:///repo/extensions/skill-registry.ts?first", "/workspace", state),
124
+ false,
125
+ );
126
+ assert.equal(
127
+ __testing.shouldSkipDuplicateExtensionLoad("file:///repo/extensions/skill-registry.ts?second", "/workspace", state),
128
+ false,
129
+ );
130
+ assert.equal(
131
+ __testing.shouldSkipDuplicateExtensionLoad("file:///home/.pi/node_modules/gentle-pi/extensions/skill-registry.ts", "/workspace", state),
132
+ true,
133
+ );
134
+ });
135
+
136
+ test("project-local skill registry extension wins over installed package copy", () => {
137
+ const cwd = join(tmpdir(), `gentle-pi-local-extension-${Date.now()}`);
138
+ const localExtension = join(cwd, "extensions", "skill-registry.ts");
139
+ mkdirSync(dirname(localExtension), { recursive: true });
140
+ writeFileSync(localExtension, "");
141
+
142
+ assert.equal(
143
+ __testing.shouldSkipDuplicateExtensionLoad(
144
+ "file:///home/.pi/agent/npm/node_modules/gentle-pi/extensions/skill-registry.ts",
145
+ cwd,
146
+ {},
147
+ ),
148
+ true,
149
+ );
150
+ assert.equal(
151
+ __testing.shouldSkipDuplicateExtensionLoad(pathToFileURL(localExtension).href, cwd, {}),
152
+ false,
153
+ );
154
+ });
155
+
118
156
  test("scope and markdown cells are represented in registry", () => {
119
157
  const cwd = join(tmpdir(), `gentle-pi-scope-${Date.now()}`);
120
158
  const projectPath = join(cwd, "skills", "docs", "SKILL.md");