@oh-my-pi/pi-coding-agent 14.9.2 → 14.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 (97) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/package.json +7 -7
  3. package/scripts/format-prompts.ts +3 -3
  4. package/src/async/job-manager.ts +66 -9
  5. package/src/capability/rule.ts +20 -0
  6. package/src/config/model-registry.ts +13 -0
  7. package/src/config/model-resolver.ts +8 -2
  8. package/src/config/prompt-templates.ts +0 -5
  9. package/src/config/settings-schema.ts +39 -1
  10. package/src/edit/index.ts +8 -0
  11. package/src/edit/renderer.ts +6 -1
  12. package/src/edit/streaming.ts +53 -2
  13. package/src/eval/eval.lark +10 -31
  14. package/src/eval/index.ts +1 -0
  15. package/src/eval/js/context-manager.ts +1 -38
  16. package/src/eval/js/prelude.txt +0 -2
  17. package/src/eval/parse.ts +156 -255
  18. package/src/eval/py/executor.ts +24 -8
  19. package/src/eval/py/index.ts +1 -0
  20. package/src/eval/py/prelude.py +11 -80
  21. package/src/eval/sniff.ts +28 -0
  22. package/src/export/html/template.css +50 -0
  23. package/src/export/html/template.generated.ts +1 -1
  24. package/src/export/html/template.js +229 -17
  25. package/src/extensibility/plugins/loader.ts +31 -6
  26. package/src/extensibility/skills.ts +20 -0
  27. package/src/hashline/constants.ts +20 -0
  28. package/src/hashline/grammar.lark +16 -23
  29. package/src/hashline/hash.ts +4 -34
  30. package/src/hashline/input.ts +16 -2
  31. package/src/hashline/parser.ts +12 -1
  32. package/src/internal-urls/agent-protocol.ts +64 -52
  33. package/src/internal-urls/artifact-protocol.ts +52 -51
  34. package/src/internal-urls/docs-index.generated.ts +34 -1
  35. package/src/internal-urls/index.ts +6 -19
  36. package/src/internal-urls/local-protocol.ts +50 -7
  37. package/src/internal-urls/mcp-protocol.ts +3 -8
  38. package/src/internal-urls/memory-protocol.ts +90 -59
  39. package/src/internal-urls/pi-protocol.ts +1 -0
  40. package/src/internal-urls/router.ts +40 -23
  41. package/src/internal-urls/rule-protocol.ts +3 -20
  42. package/src/internal-urls/skill-protocol.ts +5 -27
  43. package/src/internal-urls/types.ts +18 -2
  44. package/src/main.ts +1 -1
  45. package/src/mcp/manager.ts +17 -0
  46. package/src/modes/components/session-observer-overlay.ts +2 -2
  47. package/src/modes/components/tool-execution.ts +6 -0
  48. package/src/modes/components/tree-selector.ts +4 -0
  49. package/src/modes/controllers/event-controller.ts +23 -2
  50. package/src/modes/controllers/mcp-command-controller.ts +7 -10
  51. package/src/modes/interactive-mode.ts +2 -2
  52. package/src/modes/theme/theme.ts +27 -27
  53. package/src/modes/types.ts +1 -1
  54. package/src/modes/utils/ui-helpers.ts +14 -9
  55. package/src/prompts/commands/orchestrate.md +1 -0
  56. package/src/prompts/system/custom-system-prompt.md +0 -2
  57. package/src/prompts/system/project-prompt.md +10 -0
  58. package/src/prompts/system/subagent-system-prompt.md +18 -9
  59. package/src/prompts/system/subagent-user-prompt.md +1 -10
  60. package/src/prompts/system/system-prompt.md +159 -232
  61. package/src/prompts/tools/ask.md +0 -1
  62. package/src/prompts/tools/bash.md +0 -34
  63. package/src/prompts/tools/eval.md +27 -16
  64. package/src/prompts/tools/github.md +6 -5
  65. package/src/prompts/tools/hashline.md +1 -0
  66. package/src/prompts/tools/job.md +14 -6
  67. package/src/prompts/tools/task.md +20 -3
  68. package/src/registry/agent-registry.ts +2 -1
  69. package/src/sdk.ts +87 -89
  70. package/src/session/agent-session.ts +107 -37
  71. package/src/session/artifacts.ts +7 -4
  72. package/src/session/session-manager.ts +30 -1
  73. package/src/ssh/connection-manager.ts +32 -16
  74. package/src/ssh/sshfs-mount.ts +10 -7
  75. package/src/system-prompt.ts +3 -9
  76. package/src/task/executor.ts +23 -7
  77. package/src/task/index.ts +57 -36
  78. package/src/tool-discovery/tool-index.ts +21 -8
  79. package/src/tools/ast-edit.ts +3 -2
  80. package/src/tools/ast-grep.ts +3 -2
  81. package/src/tools/bash.ts +30 -50
  82. package/src/tools/browser/tab-supervisor.ts +12 -2
  83. package/src/tools/eval.ts +59 -44
  84. package/src/tools/fetch.ts +1 -1
  85. package/src/tools/gh.ts +140 -4
  86. package/src/tools/index.ts +12 -11
  87. package/src/tools/job.ts +48 -12
  88. package/src/tools/path-utils.ts +21 -1
  89. package/src/tools/read.ts +74 -31
  90. package/src/tools/search.ts +16 -3
  91. package/src/tools/todo-write.ts +1 -1
  92. package/src/utils/file-display-mode.ts +11 -5
  93. package/src/web/scrapers/mastodon.ts +1 -1
  94. package/src/web/scrapers/repology.ts +7 -7
  95. package/src/internal-urls/jobs-protocol.ts +0 -119
  96. package/src/task/template.ts +0 -47
  97. package/src/tools/bash-normalize.ts +0 -107
@@ -456,6 +456,10 @@
456
456
  const content = truncate(normalize(extractContent(msg.content)));
457
457
  return labelHtml + `<span class="tree-role-user">user:</span> ${escapeHtml(content)}`;
458
458
  }
459
+ if (msg.role === 'developer') {
460
+ const content = truncate(normalize(extractContent(msg.content)));
461
+ return labelHtml + `<span class="tree-role-developer">developer:</span> ${escapeHtml(content)}`;
462
+ }
459
463
  if (msg.role === 'assistant') {
460
464
  const textContent = truncate(normalize(extractContent(msg.content)));
461
465
  if (textContent) {
@@ -839,9 +843,11 @@
839
843
 
840
844
  function renderEdit(name, args, result, ctx) {
841
845
  const filePath = str(args.file_path == null ? args.path : args.file_path);
842
- const pathHtml = filePath === null ? invalidArgHtml() : escapeHtml(shortenPath(filePath || ''));
846
+ const pathHtml = filePath ? escapeHtml(shortenPath(filePath)) : '';
843
847
  let html = toolHead('edit', pathHtml);
844
- if (Array.isArray(args.edits)) {
848
+ if (typeof args.input === 'string' && args.input.length) {
849
+ html += codeBlock(args.input, null);
850
+ } else if (Array.isArray(args.edits)) {
845
851
  html += '<div class="tool-args">';
846
852
  for (const e of args.edits) {
847
853
  const op = e && typeof e.op === 'string' ? e.op : '?';
@@ -867,7 +873,8 @@
867
873
 
868
874
  function renderAstEdit(name, args, result, ctx) {
869
875
  const lang = args.lang || null;
870
- const pathHtml = args.path ? escapeHtml(shortenPath(String(args.path))) : '';
876
+ const paths = Array.isArray(args.paths) ? args.paths.map(p => shortenPath(String(p))).join(', ') : (args.path ? shortenPath(String(args.path)) : '');
877
+ const pathHtml = paths ? escapeHtml(paths) : '';
871
878
  const badges = [];
872
879
  if (lang) badges.push(lang);
873
880
  if (args.glob) badges.push('glob=' + args.glob);
@@ -931,10 +938,12 @@
931
938
  }
932
939
 
933
940
  function renderFind(name, args, result, ctx) {
934
- const pattern = str(args.pattern);
935
- const patHtml = pattern === null ? invalidArgHtml() : escapeHtml(pattern);
936
- const badges = args.limit ? ['limit=' + args.limit] : null;
937
- let html = toolHead('find', '<span class="tool-pattern">' + patHtml + '</span>', badges);
941
+ const paths = Array.isArray(args.paths) ? args.paths.map(p => shortenPath(String(p))).join(', ') : (str(args.pattern) || '.');
942
+ const patHtml = paths ? escapeHtml(paths) : invalidArgHtml();
943
+ const badges = [];
944
+ if (args.limit) badges.push('limit=' + args.limit);
945
+ if (args.hidden === false) badges.push('no-hidden');
946
+ let html = toolHead('find', '<span class="tool-pattern">' + patHtml + '</span>', badges.length ? badges : null);
938
947
  if (result) {
939
948
  const output = ctx.getResultText();
940
949
  if (output) html += formatExpandableOutput(output, 10);
@@ -1168,14 +1177,18 @@
1168
1177
  }
1169
1178
 
1170
1179
  function renderGh(name, args, result, ctx) {
1180
+ const op = str(args.op);
1171
1181
  const badges = [];
1182
+ if (op) badges.push(op);
1172
1183
  if (args.repo) badges.push(String(args.repo));
1173
1184
  if (args.issue) badges.push('#' + args.issue);
1174
- if (args.pr) badges.push('PR ' + args.pr);
1185
+ if (args.pr) badges.push(Array.isArray(args.pr) ? 'PRs ' + args.pr.join(',') : 'PR ' + args.pr);
1175
1186
  if (args.branch) badges.push('branch=' + args.branch);
1176
- if (args.query) badges.push('query=' + args.query);
1187
+ if (args.query) badges.push('query=' + truncate(String(args.query), 60));
1177
1188
  if (args.run) badges.push('run=' + args.run);
1189
+ if (args.title) badges.push('title=' + truncate(String(args.title), 40));
1178
1190
  let html = toolHead(name, '', badges);
1191
+ if (args.body) html += '<div class="tool-output"><div>' + escapeHtml(truncate(String(args.body), 400)) + '</div></div>';
1179
1192
  if (result) {
1180
1193
  const output = ctx.getResultText();
1181
1194
  if (output) html += formatExpandableOutput(output, 12, 'markdown');
@@ -1249,6 +1262,178 @@
1249
1262
  return html;
1250
1263
  }
1251
1264
 
1265
+ // Parse `*** Begin <LANG>` cell headers (canonical) and the legacy
1266
+ // `===== <info> =====` headers used by older transcripts. Cells emitted
1267
+ // before the format cutover still need to render in HTML exports.
1268
+ function parseEvalCells(input) {
1269
+ const text = String(input);
1270
+ if (/^[*]{2,}\s*Begin\b/im.test(text)) return parseEvalCellsNew(text);
1271
+ return parseEvalCellsLegacy(text);
1272
+ }
1273
+
1274
+ function evalLangAlias(token) {
1275
+ const t = String(token || '').toUpperCase();
1276
+ if (t === 'PY' || t === 'PYTHON' || t === 'IPY' || t === 'IPYTHON') return 'py';
1277
+ if (t === 'JS' || t === 'JAVASCRIPT') return 'js';
1278
+ if (t === 'TS' || t === 'TYPESCRIPT') return 'ts';
1279
+ return null;
1280
+ }
1281
+
1282
+ function parseEvalCellsNew(text) {
1283
+ const STARS = '\\*{2,}';
1284
+ const BEGIN = new RegExp('^' + STARS + '\\s*Begin\\b\\s*(\\S+)?\\s*$', 'i');
1285
+ const END = new RegExp('^' + STARS + '\\s*End\\b.*$', 'i');
1286
+ const TITLE = new RegExp('^' + STARS + '\\s*Title\\s*:\\s*(.+?)\\s*$', 'i');
1287
+ const TIMEOUT = new RegExp('^' + STARS + '\\s*Timeout\\s*:\\s*(\\S+)\\s*$', 'i');
1288
+ const RESET = new RegExp('^' + STARS + '\\s*Reset\\s*$', 'i');
1289
+ const lines = text.split('\n');
1290
+ if (lines.length && lines[lines.length - 1] === '') lines.pop();
1291
+ const cells = [];
1292
+ let i = 0;
1293
+ while (i < lines.length && lines[i].trim() === '') i++;
1294
+ while (i < lines.length) {
1295
+ const beginMatch = BEGIN.exec(lines[i]);
1296
+ if (!beginMatch) { i++; continue; }
1297
+ const lang = evalLangAlias(beginMatch[1]) || 'py';
1298
+ i++;
1299
+ let title = '';
1300
+ const attrs = [];
1301
+ while (i < lines.length) {
1302
+ const tm = TITLE.exec(lines[i]);
1303
+ if (tm) { if (!title) title = tm[1]; i++; continue; }
1304
+ const to = TIMEOUT.exec(lines[i]);
1305
+ if (to) { attrs.push('t=' + to[1]); i++; continue; }
1306
+ if (RESET.test(lines[i])) { attrs.push('rst'); i++; continue; }
1307
+ break;
1308
+ }
1309
+ const codeLines = [];
1310
+ while (i < lines.length) {
1311
+ if (END.test(lines[i])) { i++; break; }
1312
+ if (BEGIN.test(lines[i])) break;
1313
+ codeLines.push(lines[i]);
1314
+ i++;
1315
+ }
1316
+ while (codeLines.length && codeLines[codeLines.length - 1].trim() === '') codeLines.pop();
1317
+ cells.push({ lang, title, attrs, code: codeLines.join('\n') });
1318
+ while (i < lines.length && lines[i].trim() === '') i++;
1319
+ }
1320
+ return cells;
1321
+ }
1322
+
1323
+ function parseEvalCellsLegacy(input) {
1324
+ const HEADER = /^={5,}\s*(.*?)\s*={5,}\s*$/;
1325
+ const lines = String(input).split('\n');
1326
+ const cells = [];
1327
+ let inheritedLang = 'py';
1328
+ let current = null;
1329
+ for (const line of lines) {
1330
+ const m = line.match(HEADER);
1331
+ if (m) {
1332
+ if (current) cells.push(current);
1333
+ const info = m[1] || '';
1334
+ let lang = inheritedLang;
1335
+ let title = '';
1336
+ const langMatch = info.match(/^(py|js|ts)(?::"([^"]*)")?/);
1337
+ if (langMatch) {
1338
+ lang = langMatch[1];
1339
+ if (langMatch[2]) title = langMatch[2];
1340
+ }
1341
+ if (!title) {
1342
+ const idMatch = info.match(/id:"([^"]*)"/);
1343
+ if (idMatch) title = idMatch[1];
1344
+ }
1345
+ inheritedLang = lang;
1346
+ const attrs = [];
1347
+ const tMatch = info.match(/(?:^|\s)t:(\S+)/);
1348
+ if (tMatch) attrs.push('t=' + tMatch[1]);
1349
+ if (/(?:^|\s)rst(?:\s|$)/.test(info)) attrs.push('rst');
1350
+ current = { lang, title, attrs, code: '' };
1351
+ } else {
1352
+ if (!current) current = { lang: inheritedLang, title: '', attrs: [], code: '' };
1353
+ current.code += (current.code ? '\n' : '') + line;
1354
+ }
1355
+ }
1356
+ if (current) cells.push(current);
1357
+ return cells.map(c => ({ ...c, code: c.code.replace(/\s+$/, '') }));
1358
+ }
1359
+
1360
+ function evalLangToHljs(lang) {
1361
+ return lang === 'py' ? 'python' : lang === 'js' ? 'javascript' : lang === 'ts' ? 'typescript' : null;
1362
+ }
1363
+
1364
+ function renderEval(name, args, result, ctx) {
1365
+ let html = toolHead('eval');
1366
+ if (typeof args.input !== 'string') {
1367
+ html += '<div class="tool-error">[missing input]</div>';
1368
+ } else {
1369
+ const cells = parseEvalCells(args.input);
1370
+ if (cells.length === 0) {
1371
+ html += codeBlock(args.input, 'python');
1372
+ } else {
1373
+ for (const cell of cells) {
1374
+ html += '<div class="tool-cell">';
1375
+ const titleParts = [];
1376
+ if (cell.title) titleParts.push(cell.title);
1377
+ titleParts.push(cell.lang);
1378
+ if (cell.attrs && cell.attrs.length) titleParts.push(...cell.attrs);
1379
+ html += '<div class="tool-cell-title">' + escapeHtml(titleParts.join(' · ')) + '</div>';
1380
+ html += codeBlock(cell.code, evalLangToHljs(cell.lang));
1381
+ html += '</div>';
1382
+ }
1383
+ }
1384
+ }
1385
+ if (result) {
1386
+ html += ctx.renderResultImages();
1387
+ const output = ctx.getResultText();
1388
+ if (output) html += formatExpandableOutput(output, 12);
1389
+ }
1390
+ return html;
1391
+ }
1392
+
1393
+ function renderSearch(name, args, result, ctx) {
1394
+ const pattern = str(args.pattern);
1395
+ const paths = Array.isArray(args.paths) ? args.paths.map(p => shortenPath(String(p))).join(', ') : (args.path ? shortenPath(String(args.path)) : '.');
1396
+ const patHtml = pattern === null ? invalidArgHtml() : escapeHtml(pattern);
1397
+ let head = '<span class="tool-name">search</span> <span class="tool-pattern">/' + patHtml + '/</span>';
1398
+ head += ' <span class="tool-arg-key">in</span> <span class="tool-path">' + escapeHtml(paths) + '</span>';
1399
+ const badges = [];
1400
+ if (args.i) badges.push('i');
1401
+ if (args.skip) badges.push('skip=' + args.skip);
1402
+ if (args.gitignore === false) badges.push('no-gitignore');
1403
+ for (const b of badges) head += ' <span class="tool-badge">' + escapeHtml(b) + '</span>';
1404
+ let html = '<div class="tool-header">' + head + '</div>';
1405
+ if (result) {
1406
+ const output = ctx.getResultText();
1407
+ if (output) html += formatExpandableOutput(output, 12);
1408
+ }
1409
+ return html;
1410
+ }
1411
+
1412
+ function renderRecipe(name, args, result, ctx) {
1413
+ const op = str(args.op) || '?';
1414
+ let html = toolHead('recipe', '<span class="tool-arg-val">' + escapeHtml(op) + '</span>');
1415
+ if (result) {
1416
+ html += ctx.renderResultImages();
1417
+ const output = ctx.getResultText();
1418
+ if (output) html += formatExpandableOutput(output, 10);
1419
+ }
1420
+ return html;
1421
+ }
1422
+
1423
+ function renderIrc(name, args, result, ctx) {
1424
+ const op = str(args.op) || '?';
1425
+ const badges = [op];
1426
+ if (args.to) badges.push('to=' + args.to);
1427
+ if (args.awaitReply === false) badges.push('no-reply');
1428
+ let html = toolHead('irc', '', badges);
1429
+ if (args.message) html += '<div class="tool-output"><div>' + escapeHtml(String(args.message)) + '</div></div>';
1430
+ if (result) {
1431
+ const output = ctx.getResultText();
1432
+ if (output) html += formatExpandableOutput(output, 8);
1433
+ }
1434
+ return html;
1435
+ }
1436
+
1252
1437
 
1253
1438
  function renderGenericTool(name, args, result, ctx) {
1254
1439
  let html = toolHead(name);
@@ -1266,6 +1451,7 @@
1266
1451
 
1267
1452
  const TOOL_RENDERERS = {
1268
1453
  bash: renderBash,
1454
+ eval: renderEval,
1269
1455
  js: renderJsLike,
1270
1456
  python: renderJsLike,
1271
1457
  notebook: renderJsLike,
@@ -1275,6 +1461,7 @@
1275
1461
  ast_edit: renderAstEdit,
1276
1462
  ast_grep: renderAstGrep,
1277
1463
  grep: renderGrep,
1464
+ search: renderSearch,
1278
1465
  find: renderFind,
1279
1466
  lsp: renderLsp,
1280
1467
  todo_write: renderTodoWrite,
@@ -1300,16 +1487,25 @@
1300
1487
  poll: renderJob,
1301
1488
  cancel_job: renderJob,
1302
1489
  job: renderJob,
1490
+ recipe: renderRecipe,
1491
+ irc: renderIrc,
1303
1492
  };
1304
1493
 
1305
1494
  function renderToolCall(call) {
1306
1495
  const result = findToolResult(call.id);
1307
1496
  const isError = result?.isError || false;
1308
1497
  const statusClass = result ? (isError ? 'error' : 'success') : 'pending';
1309
- const args = call.arguments || {};
1498
+ const rawArgs = call.arguments || {};
1499
+ const intent = typeof rawArgs._i === 'string' ? rawArgs._i.trim() : '';
1500
+ // Strip internal _i intent so renderers don't dump it as JSON.
1501
+ const args = {};
1502
+ for (const k of Object.keys(rawArgs)) {
1503
+ if (k !== '_i') args[k] = rawArgs[k];
1504
+ }
1310
1505
  const name = call.name;
1311
1506
 
1312
1507
  const ctx = {
1508
+ intent,
1313
1509
  getResultText: () => {
1314
1510
  if (!result) return '';
1315
1511
  const textBlocks = result.content.filter(c => c.type === 'text');
@@ -1331,6 +1527,7 @@
1331
1527
 
1332
1528
  const renderer = TOOL_RENDERERS[name] || renderGenericTool;
1333
1529
  let html = '<div class="tool-execution ' + statusClass + '">';
1530
+ if (intent) html += '<div class="tool-intent">' + escapeHtml(intent) + '</div>';
1334
1531
  try {
1335
1532
  html += renderer(name, args, result, ctx);
1336
1533
  } catch (err) {
@@ -1455,6 +1652,18 @@
1455
1652
  return html;
1456
1653
  }
1457
1654
 
1655
+ if (msg.role === 'developer') {
1656
+ let html = `<div class="user-message developer-message" id="${entryId}">${copyBtnHtml}${tsHtml}`;
1657
+ const content = msg.content;
1658
+ const text = typeof content === 'string' ? content :
1659
+ content.filter(c => c.type === 'text').map(c => c.text).join('\n');
1660
+ if (text.trim()) {
1661
+ html += `<div class="markdown-content">${safeMarkedParse(text)}</div>`;
1662
+ }
1663
+ html += '</div>';
1664
+ return html;
1665
+ }
1666
+
1458
1667
  if (msg.role === 'assistant') {
1459
1668
  let html = `<div class="assistant-message" id="${entryId}">${copyBtnHtml}${tsHtml}`;
1460
1669
 
@@ -1557,7 +1766,7 @@
1557
1766
  // ============================================================
1558
1767
 
1559
1768
  function computeStats(entryList) {
1560
- let userMessages = 0, assistantMessages = 0, toolResults = 0;
1769
+ let userMessages = 0, developerMessages = 0, assistantMessages = 0, toolResults = 0;
1561
1770
  let customMessages = 0, compactions = 0, branchSummaries = 0, toolCalls = 0;
1562
1771
  const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
1563
1772
  const cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
@@ -1567,6 +1776,7 @@
1567
1776
  if (entry.type === 'message') {
1568
1777
  const msg = entry.message;
1569
1778
  if (msg.role === 'user') userMessages++;
1779
+ if (msg.role === 'developer') developerMessages++;
1570
1780
  if (msg.role === 'assistant') {
1571
1781
  assistantMessages++;
1572
1782
  if (msg.model) models.add(msg.provider ? `${msg.provider}/${msg.model}` : msg.model);
@@ -1594,7 +1804,7 @@
1594
1804
  }
1595
1805
  }
1596
1806
 
1597
- return { userMessages, assistantMessages, toolResults, customMessages, compactions, branchSummaries, toolCalls, tokens, cost, models: Array.from(models) };
1807
+ return { userMessages, developerMessages, assistantMessages, toolResults, customMessages, compactions, branchSummaries, toolCalls, tokens, cost, models: Array.from(models) };
1598
1808
  }
1599
1809
 
1600
1810
  const globalStats = computeStats(entries);
@@ -1610,6 +1820,7 @@
1610
1820
 
1611
1821
  const msgParts = [];
1612
1822
  if (globalStats.userMessages) msgParts.push(`${globalStats.userMessages} user`);
1823
+ if (globalStats.developerMessages) msgParts.push(`${globalStats.developerMessages} developer`);
1613
1824
  if (globalStats.assistantMessages) msgParts.push(`${globalStats.assistantMessages} assistant`);
1614
1825
  if (globalStats.toolResults) msgParts.push(`${globalStats.toolResults} tool results`);
1615
1826
  if (globalStats.customMessages) msgParts.push(`${globalStats.customMessages} custom`);
@@ -1638,11 +1849,12 @@
1638
1849
  }
1639
1850
 
1640
1851
  if (tools && tools.length > 0) {
1641
- html += `<div class="tools-list">
1642
- <div class="tools-header">Available Tools</div>
1643
- <div class="tools-content">
1644
- ${tools.map(t => `<div class="tool-item"><span class="tool-item-name">${escapeHtml(t.name)}</span> - <span class="tool-item-desc">${escapeHtml(t.description)}</span></div>`).join('')}
1645
- </div>
1852
+ const namesHtml = tools.map(t => `<span class="tool-name-chip">${escapeHtml(t.name)}</span>`).join('');
1853
+ const fullHtml = tools.map(t => `<div class="tool-item"><span class="tool-item-name">${escapeHtml(t.name)}</span> - <span class="tool-item-desc">${escapeHtml(t.description)}</span></div>`).join('');
1854
+ html += `<div class="tools-list collapsed" onclick="this.classList.toggle('collapsed')">
1855
+ <div class="tools-header">Available Tools (${tools.length})</div>
1856
+ <div class="tools-collapsed">${namesHtml}</div>
1857
+ <div class="tools-content">${fullHtml}</div>
1646
1858
  </div>`;
1647
1859
  }
1648
1860
 
@@ -117,6 +117,31 @@ export async function getEnabledPlugins(cwd: string): Promise<InstalledPlugin[]>
117
117
  // Path Resolution
118
118
  // =============================================================================
119
119
 
120
+ const MANIFEST_ENTRY_INDEX_NAMES = ["index.ts", "index.js", "index.mjs", "index.cjs"];
121
+
122
+ /**
123
+ * Resolve a plugin manifest entry to a concrete loadable file path. Returns the
124
+ * file path itself when the entry points at a file, the matching index file when
125
+ * the entry points at a directory containing index.{ts,js,mjs,cjs}, and null
126
+ * when no entry exists at the joined path.
127
+ */
128
+ function resolveManifestEntryFile(joined: string): string | null {
129
+ let stats: fs.Stats;
130
+ try {
131
+ stats = fs.statSync(joined);
132
+ } catch {
133
+ return null;
134
+ }
135
+ if (stats.isDirectory()) {
136
+ for (const name of MANIFEST_ENTRY_INDEX_NAMES) {
137
+ const candidate = path.join(joined, name);
138
+ if (fs.existsSync(candidate)) return candidate;
139
+ }
140
+ return null;
141
+ }
142
+ return joined;
143
+ }
144
+
120
145
  /**
121
146
  * Generic path resolver for plugin manifest entries (tools, hooks, commands, extensions).
122
147
  * Handles both single-string and string[] base entries, plus feature-specific entries.
@@ -130,8 +155,8 @@ function resolvePluginPaths(plugin: InstalledPlugin, key: "tools" | "hooks" | "c
130
155
  if (base) {
131
156
  const entries = Array.isArray(base) ? base : [base];
132
157
  for (const entry of entries) {
133
- const resolved = path.join(plugin.path, entry);
134
- if (fs.existsSync(resolved)) {
158
+ const resolved = resolveManifestEntryFile(path.join(plugin.path, entry));
159
+ if (resolved) {
135
160
  paths.push(resolved);
136
161
  }
137
162
  }
@@ -146,8 +171,8 @@ function resolvePluginPaths(plugin: InstalledPlugin, key: "tools" | "hooks" | "c
146
171
 
147
172
  if (feat[key]) {
148
173
  for (const entry of feat[key]) {
149
- const resolved = path.join(plugin.path, entry);
150
- if (fs.existsSync(resolved)) {
174
+ const resolved = resolveManifestEntryFile(path.join(plugin.path, entry));
175
+ if (resolved) {
151
176
  paths.push(resolved);
152
177
  }
153
178
  }
@@ -160,8 +185,8 @@ function resolvePluginPaths(plugin: InstalledPlugin, key: "tools" | "hooks" | "c
160
185
 
161
186
  if (feat[key]) {
162
187
  for (const entry of feat[key]) {
163
- const resolved = path.join(plugin.path, entry);
164
- if (fs.existsSync(resolved)) {
188
+ const resolved = resolveManifestEntryFile(path.join(plugin.path, entry));
189
+ if (resolved) {
165
190
  paths.push(resolved);
166
191
  }
167
192
  }
@@ -28,6 +28,26 @@ export interface LoadSkillsResult {
28
28
  warnings: SkillWarning[];
29
29
  }
30
30
 
31
+ let activeSkills: readonly Skill[] = [];
32
+
33
+ /**
34
+ * Process-global snapshot of skills the active session loaded.
35
+ * Read by internal URL protocol handlers (skill://).
36
+ */
37
+ export function getActiveSkills(): readonly Skill[] {
38
+ return activeSkills;
39
+ }
40
+
41
+ /** Replace the active skill snapshot. Called once per top-level session. */
42
+ export function setActiveSkills(value: readonly Skill[]): void {
43
+ activeSkills = value;
44
+ }
45
+
46
+ /** Reset the active skill snapshot. Test-only. */
47
+ export function resetActiveSkillsForTests(): void {
48
+ activeSkills = [];
49
+ }
50
+
31
51
  export interface LoadSkillsFromDirOptions {
32
52
  /** Directory to scan for skills */
33
53
  dir: string;
@@ -6,3 +6,23 @@ export const RANGE_INTERIOR_HASH = "**";
6
6
 
7
7
  /** Header marker introducing a new file section in multi-section input. */
8
8
  export const FILE_HEADER_PREFIX = "@";
9
+
10
+ /** Optional patch envelope start marker; silently consumed when present. */
11
+ export const BEGIN_PATCH_MARKER = "*** Begin Patch";
12
+
13
+ /** Optional patch envelope end marker; terminates parsing when encountered. */
14
+ export const END_PATCH_MARKER = "*** End Patch";
15
+
16
+ /**
17
+ * Recovery sentinel emitted by the agent loop when a contaminated
18
+ * `to=functions.edit` stream is truncated mid-call (see
19
+ * `docs/ERRATA-GPT5-HARMONY.md`). Behaves like `END_PATCH_MARKER` for
20
+ * parsing — terminates the line loop — and additionally surfaces a
21
+ * warning in the tool result so the model knows to re-issue any
22
+ * remaining edits.
23
+ */
24
+ export const ABORT_MARKER = "*** Abort";
25
+
26
+ /** Warning text appended to the tool result when ABORT_MARKER terminates parsing. */
27
+ export const ABORT_WARNING =
28
+ "Tool stream truncated mid-call due to detected output corruption. Applied ops above are valid. Re-issue any remaining edits.";
@@ -1,29 +1,22 @@
1
- %import common.LF
2
- %import common.WS_INLINE
3
-
4
- start: section+
5
-
6
- section: file_header line_op*
7
-
8
- file_header: "@" path LF
1
+ start: begin_patch hunk+ end_patch
2
+ begin_patch: "*** Begin Patch" LF
3
+ end_patch: "*** End Patch" LF?
9
4
 
10
- line_op: insert_before_op payload+
11
- | insert_after_op payload+
12
- | replace_op payload*
13
- | delete_op
14
- | blank
5
+ hunk: update_hunk
6
+ update_hunk: "@" filename LF line_op*
15
7
 
16
- insert_before_op: "<" insert_target LF
17
- insert_after_op: "+" insert_target LF
18
- replace_op: "=" range LF
19
- delete_op: "-" range LF
20
- payload: $HSEP$ line_text? LF
8
+ filename: /(.+)/
21
9
 
22
- line_text: /[^\r\n]+/
10
+ line_op: insert_before | insert_after | replace | delete | blank
11
+ insert_before: ("<" | "< ") anchor LF payload+
12
+ insert_after: ("+" | "+ ") anchor LF payload+
13
+ replace: ("=" | "= ") range LF payload*
14
+ delete: ("-" | "- ") range LF
15
+ payload: $HSEP$ /(.*)/ LF
16
+ blank: LF
23
17
 
24
- insert_target: LID | "EOF" | "BOF"
18
+ anchor: LID | "EOF" | "BOF"
25
19
  range: LID ".." LID
20
+ LID: /[1-9]\d*$HFMT$/
26
21
 
27
- path: /(?:[^\s\r\n]+|"[^"\r\n]+"|'[^'\r\n]+')/
28
- LID: /[1-9][0-9]*$HFMT$/
29
- blank: LF
22
+ %import common.LF
@@ -137,48 +137,18 @@ export const HL_BODY_SEP = "|";
137
137
  export const HL_BODY_SEP_RE_RAW = regexEscape(HL_BODY_SEP);
138
138
 
139
139
  const RE_SIGNIFICANT = /[\p{L}\p{N}]/u;
140
- const RE_STRUCTURAL_STRIP = /[\s{}]/g;
141
-
142
- /**
143
- * Bigram returned for lines that contain only whitespace and `{`/`}`.
144
- * Picks the English ordinal suffix for the line number (`1` → `st`,
145
- * `2` → `nd`, `3` → `rd`, `11`/`12`/`13` → `th`, else `th`) so the
146
- * line digits + bigram BPE-merge into a single ordinal token (`1st`, `42nd`,
147
- * `100th`, …). Brace-only lines therefore cost one token for the whole
148
- * `LINE+ID` anchor instead of two.
149
- */
150
- function structuralBigram(line: number): string {
151
- const mod100 = line % 100;
152
- if (mod100 >= 11 && mod100 <= 13) return "th";
153
- switch (line % 10) {
154
- case 1:
155
- return "st";
156
- case 2:
157
- return "nd";
158
- case 3:
159
- return "rd";
160
- default:
161
- return "th";
162
- }
163
- }
164
140
 
165
141
  /**
166
142
  * Compute a 2-character hash of a single line via xxHash32 mod 647 over
167
- * {@link HL_BIGRAMS}. Lines that contain only whitespace and `{`/`}` collapse
168
- * to an ordinal-suffix bigram (see {@link structuralBigram}) so brace-only
169
- * structure shares one merged ordinal token (`1st`, `42nd`, `100th`, …).
170
- * Other lines with no letter or digit mix the line number into the seed so
171
- * adjacent identical punctuation-only lines get distinct hashes; lines with
172
- * significant content stay line-number-independent so a line is identifiable
173
- * across small shifts.
143
+ * {@link HL_BIGRAMS}. Lines with no letter or digit mix the line number
144
+ * into the seed so adjacent identical punctuation-only lines (e.g. brace-only
145
+ * lines) get distinct hashes; lines with significant content stay
146
+ * line-number-independent so a line is identifiable across small shifts.
174
147
  *
175
148
  * The line input should not include a trailing newline.
176
149
  */
177
150
  export function computeLineHash(idx: number, line: string): string {
178
151
  line = line.replace(/\r/g, "").trimEnd();
179
- if (line.replace(RE_STRUCTURAL_STRIP, "").length === 0) {
180
- return structuralBigram(idx);
181
- }
182
152
  const seed = RE_SIGNIFICANT.test(line) ? 0 : idx;
183
153
  return HL_BIGRAMS[Bun.hash.xxHash32(line, seed) % HL_BIGRAMS_COUNT];
184
154
  }
@@ -1,5 +1,5 @@
1
1
  import * as path from "node:path";
2
- import { FILE_HEADER_PREFIX } from "./constants";
2
+ import { ABORT_MARKER, BEGIN_PATCH_MARKER, END_PATCH_MARKER, FILE_HEADER_PREFIX } from "./constants";
3
3
  import { HL_EDIT_SEP } from "./hash";
4
4
  import type { SplitHashlineOptions } from "./types";
5
5
  import { stripTrailingCarriageReturn } from "./utils";
@@ -38,10 +38,22 @@ function parseHashlineHeaderLine(line: string, cwd?: string): HashlineInputSecti
38
38
  return { path: parsedPath, diff: "" };
39
39
  }
40
40
 
41
+ function isPatchEnvelopeMarker(line: string): boolean {
42
+ const trimmed = line.trimEnd();
43
+ return trimmed === BEGIN_PATCH_MARKER || trimmed === END_PATCH_MARKER;
44
+ }
45
+
41
46
  function stripLeadingBlankLines(input: string): string {
42
47
  const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
43
48
  const lines = stripped.split("\n");
44
- while (lines.length > 0 && lines[0].replace(/\r$/, "").trim().length === 0) lines.shift();
49
+ while (lines.length > 0) {
50
+ const head = lines[0].replace(/\r$/, "");
51
+ if (head.trim().length === 0 || head.trimEnd() === BEGIN_PATCH_MARKER) {
52
+ lines.shift();
53
+ continue;
54
+ }
55
+ break;
56
+ }
45
57
  return lines.join("\n");
46
58
  }
47
59
 
@@ -96,6 +108,8 @@ export function splitHashlineInputs(input: string, options: SplitHashlineOptions
96
108
 
97
109
  for (const rawLine of lines) {
98
110
  const line = stripTrailingCarriageReturn(rawLine);
111
+ if (line.trimEnd() === END_PATCH_MARKER || line.trimEnd() === ABORT_MARKER) break;
112
+ if (isPatchEnvelopeMarker(line)) continue;
99
113
  const header = parseHashlineHeaderLine(line, options.cwd);
100
114
  if (header !== null) {
101
115
  flush();
@@ -1,4 +1,4 @@
1
- import { RANGE_INTERIOR_HASH } from "./constants";
1
+ import { ABORT_MARKER, ABORT_WARNING, BEGIN_PATCH_MARKER, END_PATCH_MARKER, RANGE_INTERIOR_HASH } from "./constants";
2
2
  import { describeAnchorExamples, HL_EDIT_SEP, HL_HASH_CAPTURE_RE_RAW } from "./hash";
3
3
  import type { Anchor, HashlineCursor, HashlineEdit } from "./types";
4
4
  import { stripTrailingCarriageReturn } from "./utils";
@@ -118,6 +118,17 @@ export function parseHashlineWithWarnings(diff: string): { edits: HashlineEdit[]
118
118
  i++;
119
119
  continue;
120
120
  }
121
+ if (line === END_PATCH_MARKER) {
122
+ break;
123
+ }
124
+ if (line === ABORT_MARKER) {
125
+ warnings.push(ABORT_WARNING);
126
+ break;
127
+ }
128
+ if (line === BEGIN_PATCH_MARKER) {
129
+ i++;
130
+ continue;
131
+ }
121
132
  if (line.startsWith(HL_EDIT_SEP)) {
122
133
  throw new Error(`line ${lineNum}: payload line has no preceding +, <, or = operation.`);
123
134
  }