@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
@@ -6,6 +6,8 @@ import { isEnoent } from "@oh-my-pi/pi-utils";
6
6
 
7
7
  const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
8
8
  const FILE_LINE_RANGE_RE = /^(?:L?\d+(?:[-+]L?\d+)?|raw)$/i;
9
+ const FILE_LINE_RANGE_ONLY_RE = /^L?\d+(?:[-+]L?\d+)?$/i;
10
+ const FILE_RAW_ONLY_RE = /^raw$/i;
9
11
  const NARROW_NO_BREAK_SPACE = "\u202F";
10
12
  const TOP_LEVEL_INTERNAL_URL_PREFIXES = [
11
13
  "agent://",
@@ -110,7 +112,25 @@ export function splitPathAndSel(rawPath: string): { path: string; sel?: string }
110
112
  const candidate = rawPath.slice(colon + 1);
111
113
  if (!FILE_LINE_RANGE_RE.test(candidate)) return { path: rawPath };
112
114
 
113
- return { path: rawPath.slice(0, colon), sel: candidate };
115
+ let basePath = rawPath.slice(0, colon);
116
+ let sel = candidate;
117
+
118
+ // Allow a compound trailing selector: `path:1-50:raw` or `path:raw:1-50`.
119
+ // The two chunks must be one line-range plus one `raw`, in either order.
120
+ const innerColon = basePath.lastIndexOf(":");
121
+ if (innerColon > 0) {
122
+ const innerCandidate = basePath.slice(innerColon + 1);
123
+ const innerIsRaw = FILE_RAW_ONLY_RE.test(innerCandidate);
124
+ const outerIsRaw = FILE_RAW_ONLY_RE.test(candidate);
125
+ const innerIsRange = FILE_LINE_RANGE_ONLY_RE.test(innerCandidate);
126
+ const outerIsRange = FILE_LINE_RANGE_ONLY_RE.test(candidate);
127
+ if ((innerIsRaw && outerIsRange) || (innerIsRange && outerIsRaw)) {
128
+ sel = `${innerCandidate}:${candidate}`;
129
+ basePath = basePath.slice(0, innerColon);
130
+ }
131
+ }
132
+
133
+ return { path: basePath, sel };
114
134
  }
115
135
 
116
136
  function assertNotInternalUrl(expanded: string, original: string): void {
package/src/tools/read.ts CHANGED
@@ -12,6 +12,7 @@ import { getFileReadCache } from "../edit/file-read-cache";
12
12
  import { isNotebookPath, readEditableNotebookText } from "../edit/notebook";
13
13
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
14
14
  import { formatHashLine, formatHashLines, formatLineHash, HL_BODY_SEP } from "../hashline/hash";
15
+ import { InternalUrlRouter } from "../internal-urls";
15
16
  import { parseInternalUrl } from "../internal-urls/parse";
16
17
  import type { InternalUrl } from "../internal-urls/types";
17
18
  import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
@@ -431,7 +432,7 @@ function prependSuffixResolutionNotice(text: string, suffixResolution?: { from:
431
432
  const readSchema = Type.Object({
432
433
  path: Type.String({
433
434
  description: 'path or url; append :<sel> for line ranges or raw mode (e.g. "src/foo.ts:50-100")',
434
- examples: ["src/foo.ts", "src/foo.ts:50-100", "https://example.com:L1-L40"],
435
+ examples: ["src/foo.ts", "src/foo.ts:50-100", "https://example.com/:1-40"],
435
436
  }),
436
437
  });
437
438
 
@@ -462,34 +463,67 @@ type ReadParams = ReadToolInput;
462
463
  type ParsedSelector =
463
464
  | { kind: "none" }
464
465
  | { kind: "raw" }
465
- | { kind: "lines"; startLine: number; endLine: number | undefined };
466
+ | { kind: "lines"; startLine: number; endLine: number | undefined; raw?: boolean };
466
467
 
467
468
  const LINE_RANGE_RE = /^L?(\d+)(?:([-+])L?(\d+))?$/i;
468
469
 
469
- function parseSel(sel: string | undefined): ParsedSelector {
470
- if (!sel || sel.length === 0) return { kind: "none" };
471
- if (sel.toLowerCase() === "raw") return { kind: "raw" };
470
+ /** Returns true when the selector requested verbatim/raw output (alone or combined with a range). */
471
+ function isRawSelector(parsed: ParsedSelector): boolean {
472
+ return parsed.kind === "raw" || (parsed.kind === "lines" && parsed.raw === true);
473
+ }
474
+
475
+ function parseLineRangeChunk(sel: string): { startLine: number; endLine: number | undefined } | null {
472
476
  const lineMatch = LINE_RANGE_RE.exec(sel);
473
- if (lineMatch) {
474
- const rawStart = Number.parseInt(lineMatch[1]!, 10);
475
- if (rawStart < 1) {
476
- throw new ToolError("Line selector 0 is invalid; lines are 1-indexed. Use :1.");
477
+ if (!lineMatch) return null;
478
+ const rawStart = Number.parseInt(lineMatch[1]!, 10);
479
+ if (rawStart < 1) {
480
+ throw new ToolError("Line selector 0 is invalid; lines are 1-indexed. Use :1.");
481
+ }
482
+ const sep = lineMatch[2];
483
+ const rhs = lineMatch[3] ? Number.parseInt(lineMatch[3], 10) : undefined;
484
+ let rawEnd: number | undefined;
485
+ if (sep === "+") {
486
+ if (rhs === undefined || rhs < 1) {
487
+ throw new ToolError(`Invalid range ${rawStart}+${rhs ?? 0}: count must be >= 1.`);
477
488
  }
478
- const sep = lineMatch[2];
479
- const rhs = lineMatch[3] ? Number.parseInt(lineMatch[3], 10) : undefined;
480
- let rawEnd: number | undefined;
481
- if (sep === "+") {
482
- if (rhs === undefined || rhs < 1) {
483
- throw new ToolError(`Invalid range ${rawStart}+${rhs ?? 0}: count must be >= 1.`);
484
- }
485
- rawEnd = rawStart + rhs - 1;
486
- } else if (sep === "-") {
487
- if (rhs === undefined || rhs < rawStart) {
488
- throw new ToolError(`Invalid range ${rawStart}-${rhs ?? 0}: end must be >= start.`);
489
+ rawEnd = rawStart + rhs - 1;
490
+ } else if (sep === "-") {
491
+ if (rhs === undefined || rhs < rawStart) {
492
+ throw new ToolError(`Invalid range ${rawStart}-${rhs ?? 0}: end must be >= start.`);
493
+ }
494
+ rawEnd = rhs;
495
+ }
496
+ return { startLine: rawStart, endLine: rawEnd };
497
+ }
498
+
499
+ function parseSel(sel: string | undefined): ParsedSelector {
500
+ if (!sel || sel.length === 0) return { kind: "none" };
501
+
502
+ // Compound selector: `1-50:raw` or `raw:1-50`. Split into chunks and accept
503
+ // any combination of one line range and the literal `raw`.
504
+ if (sel.includes(":")) {
505
+ const chunks = sel.split(":");
506
+ if (chunks.length === 2) {
507
+ const [a, b] = chunks as [string, string];
508
+ const aIsRaw = a.toLowerCase() === "raw";
509
+ const bIsRaw = b.toLowerCase() === "raw";
510
+ const rangeChunk = aIsRaw ? b : bIsRaw ? a : null;
511
+ const rawChunk = aIsRaw ? a : bIsRaw ? b : null;
512
+ if (rangeChunk !== null && rawChunk !== null) {
513
+ const range = parseLineRangeChunk(rangeChunk);
514
+ if (range) {
515
+ return { kind: "lines", startLine: range.startLine, endLine: range.endLine, raw: true };
516
+ }
489
517
  }
490
- rawEnd = rhs;
491
518
  }
492
- return { kind: "lines", startLine: rawStart, endLine: rawEnd };
519
+ // Unrecognized compound fall through (sqlite/archive/url consume their own colon syntax).
520
+ return { kind: "none" };
521
+ }
522
+
523
+ if (sel.toLowerCase() === "raw") return { kind: "raw" };
524
+ const range = parseLineRangeChunk(sel);
525
+ if (range) {
526
+ return { kind: "lines", startLine: range.startLine, endLine: range.endLine };
493
527
  }
494
528
  // Unrecognized selectors fall through; sqlite/archive/url readers consume their own colon syntax.
495
529
  return { kind: "none" };
@@ -653,9 +687,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
653
687
  entityLabel: string;
654
688
  ignoreResultLimits?: boolean;
655
689
  raw?: boolean;
690
+ immutable?: boolean;
656
691
  },
657
692
  ): AgentToolResult<ReadToolDetails> {
658
- const displayMode = resolveFileDisplayMode(this.session, { raw: options.raw });
693
+ const displayMode = resolveFileDisplayMode(this.session, { raw: options.raw, immutable: options.immutable });
659
694
  const details = options.details ?? {};
660
695
  const allLines = text.split("\n");
661
696
  const totalLines = allLines.length;
@@ -1139,6 +1174,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1139
1174
  details: { ...cached.details },
1140
1175
  sourceUrl: cached.details.finalUrl,
1141
1176
  entityLabel: "URL output",
1177
+ immutable: true,
1142
1178
  });
1143
1179
  }
1144
1180
  return executeReadUrl(this.session, { path: parsedUrlTarget.path, raw: parsedUrlTarget.raw }, signal);
@@ -1146,11 +1182,11 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1146
1182
 
1147
1183
  // Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://)
1148
1184
  const internalTarget = splitPathAndSel(readPath);
1149
- const internalRouter = this.session.internalRouter;
1150
- if (internalRouter?.canHandle(internalTarget.path)) {
1185
+ const internalRouter = InternalUrlRouter.instance();
1186
+ if (internalRouter.canHandle(internalTarget.path)) {
1151
1187
  const parsed = parseSel(internalTarget.sel);
1152
1188
  const { offset, limit } = selToOffsetLimit(parsed);
1153
- return this.#handleInternalUrl(internalTarget.path, offset, limit);
1189
+ return this.#handleInternalUrl(internalTarget.path, offset, limit, { raw: isRawSelector(parsed) });
1154
1190
  }
1155
1191
 
1156
1192
  const archivePath = await this.#resolveArchiveReadPath(readPath, signal);
@@ -1164,7 +1200,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1164
1200
  limit,
1165
1201
  { ...archivePath, archiveSubPath: archiveSubPath.path },
1166
1202
  signal,
1167
- { raw: archiveParsed.kind === "raw" },
1203
+ { raw: isRawSelector(archiveParsed) },
1168
1204
  );
1169
1205
  }
1170
1206
 
@@ -1293,7 +1329,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1293
1329
  throw error;
1294
1330
  }
1295
1331
  }
1296
- } else if (isNotebookPath(absolutePath) && parsed.kind !== "raw") {
1332
+ } else if (isNotebookPath(absolutePath) && !isRawSelector(parsed)) {
1297
1333
  const { offset, limit } = selToOffsetLimit(parsed);
1298
1334
  return this.#buildInMemoryTextResult(
1299
1335
  await readEditableNotebookText(absolutePath, localReadPath),
@@ -1421,7 +1457,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1421
1457
  getFileReadCache(this.session).recordContiguous(absolutePath, startLineDisplay, collectedLines);
1422
1458
  }
1423
1459
 
1424
- const isRawMode = parsed.kind === "raw";
1460
+ const isRawMode = isRawSelector(parsed);
1425
1461
  const shouldAddHashLines = !isRawMode && displayMode.hashLines;
1426
1462
  const shouldAddLineNumbers = isRawMode ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
1427
1463
  let capturedDisplayContent: { text: string; startLine: number } | undefined;
@@ -1510,8 +1546,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1510
1546
  * Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://).
1511
1547
  * Supports pagination via offset/limit but rejects them when query extraction is used.
1512
1548
  */
1513
- async #handleInternalUrl(url: string, offset?: number, limit?: number): Promise<AgentToolResult<ReadToolDetails>> {
1514
- const internalRouter = this.session.internalRouter!;
1549
+ async #handleInternalUrl(
1550
+ url: string,
1551
+ offset?: number,
1552
+ limit?: number,
1553
+ options?: { raw?: boolean },
1554
+ ): Promise<AgentToolResult<ReadToolDetails>> {
1555
+ const internalRouter = InternalUrlRouter.instance();
1515
1556
 
1516
1557
  // Check if URL has query extraction (agent:// only).
1517
1558
  // Use parseInternalUrl which handles colons in host (namespaced skills).
@@ -1550,6 +1591,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1550
1591
  sourceInternal: url,
1551
1592
  entityLabel: "resource",
1552
1593
  ignoreResultLimits: scheme === "skill",
1594
+ immutable: resource.immutable,
1595
+ raw: options?.raw,
1553
1596
  });
1554
1597
  }
1555
1598
 
@@ -8,6 +8,7 @@ import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
8
  import { type Static, Type } from "@sinclair/typebox";
9
9
  import { getFileReadCache } from "../edit/file-read-cache";
10
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
+ import { InternalUrlRouter } from "../internal-urls";
11
12
  import type { Theme } from "../modes/theme/theme";
12
13
  import searchDescription from "../prompts/tools/search.md" with { type: "text" };
13
14
  import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "../session/streaming-output";
@@ -121,7 +122,6 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
121
122
  const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n");
122
123
  const effectiveMultiline = patternHasNewline;
123
124
 
124
- const useHashLines = resolveFileDisplayMode(this.session).hashLines;
125
125
  const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
126
126
  let searchPath: string;
127
127
  let scopePath: string;
@@ -132,10 +132,14 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
132
132
  if (rawPaths.some(rawPath => rawPath.length === 0)) {
133
133
  throw new ToolError("`paths` must contain non-empty paths or globs");
134
134
  }
135
- const internalRouter = this.session.internalRouter;
135
+ const internalRouter = InternalUrlRouter.instance();
136
136
  const resolvedPathInputs: string[] = [];
137
+ // Absolute filesystem paths whose source is immutable (e.g. artifact://,
138
+ // pi://, skill://). Hashline anchors are suppressed for these on a
139
+ // per-file basis, leaving editable mixed-in files untouched.
140
+ const immutableSourcePaths = new Set<string>();
137
141
  for (const rawPath of rawPaths) {
138
- if (!internalRouter?.canHandle(rawPath)) {
142
+ if (!internalRouter.canHandle(rawPath)) {
139
143
  resolvedPathInputs.push(rawPath);
140
144
  continue;
141
145
  }
@@ -146,8 +150,13 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
146
150
  if (!resource.sourcePath) {
147
151
  throw new ToolError(`Cannot search internal URL without a backing file: ${rawPath}`);
148
152
  }
153
+ if (resource.immutable) {
154
+ immutableSourcePaths.add(path.resolve(resource.sourcePath));
155
+ }
149
156
  resolvedPathInputs.push(resource.sourcePath);
150
157
  }
158
+ const baseDisplayMode = resolveFileDisplayMode(this.session);
159
+ const immutableDisplayMode = resolveFileDisplayMode(this.session, { immutable: true });
151
160
  // Tolerate missing entries in a multi-path call: skip ones whose base
152
161
  // directory is gone, and only error if every entry is missing. Single
153
162
  // missing path keeps the original ENOENT semantics.
@@ -336,6 +345,10 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
336
345
  const modelOut: string[] = [];
337
346
  const displayOut: string[] = [];
338
347
  const fileMatches = matchesByFile.get(relativePath) ?? [];
348
+ const absoluteFilePath = path.resolve(this.session.cwd, relativePath);
349
+ const useHashLines = immutableSourcePaths.has(absoluteFilePath)
350
+ ? immutableDisplayMode.hashLines
351
+ : baseDisplayMode.hashLines;
339
352
  const lineNumberWidth = fileMatches.reduce((width, match) => {
340
353
  let nextWidth = Math.max(width, String(match.lineNumber).length);
341
354
  for (const ctx of match.contextBefore ?? []) {
@@ -631,7 +631,7 @@ function renderNoteAttachments(phases: TodoPhase[], uiTheme: Theme): string[] {
631
631
  for (const task of phase.tasks) {
632
632
  if (task.status !== "in_progress" || !task.notes || task.notes.length === 0) continue;
633
633
  const bar = uiTheme.fg("dim", uiTheme.tree.vertical);
634
- const title = uiTheme.fg("dim", chalk.italic(`\u00a7 notes \u2014 ${task.content}`));
634
+ const title = uiTheme.fg("dim", chalk.italic( notes ${task.content}`));
635
635
  lines.push("");
636
636
  lines.push(` ${title}`);
637
637
  for (let j = 0; j < task.notes.length; j++) {
@@ -21,17 +21,23 @@ export interface FileDisplayModeSession {
21
21
  /**
22
22
  * Computes effective line display mode from session settings/env.
23
23
  * Hashline mode takes precedence and implies line-addressed output everywhere.
24
- * Hashlines are suppressed when the edit tool is not available (e.g. explore agents)
25
- * and when the caller signals a `raw` read raw output should be returned as-is
26
- * without injecting hashline anchors or line numbers.
24
+ * Hashlines are suppressed when the edit tool is not available (e.g. explore agents),
25
+ * when the caller signals a `raw` read, and when the source is `immutable`
26
+ * (e.g. internal URLs like artifact://, agent://, memory:// — there is no edit
27
+ * path that could consume the anchors). Raw output is returned as-is.
27
28
  */
28
- export function resolveFileDisplayMode(session: FileDisplayModeSession, options?: { raw?: boolean }): FileDisplayMode {
29
+ export function resolveFileDisplayMode(
30
+ session: FileDisplayModeSession,
31
+ options?: { raw?: boolean; immutable?: boolean },
32
+ ): FileDisplayMode {
29
33
  const { settings } = session;
30
34
  const hasEditTool = session.hasEditTool ?? true;
31
35
  const editMode = resolveEditMode(session);
32
36
  const usesHashLineAnchors = editMode === "hashline";
33
37
  const raw = options?.raw === true;
34
- const hashLines = !raw && hasEditTool && usesHashLineAnchors && settings.get("readHashLines") !== false;
38
+ const immutable = options?.immutable === true;
39
+ const hashLines =
40
+ !raw && !immutable && hasEditTool && usesHashLineAnchors && settings.get("readHashLines") !== false;
35
41
  return {
36
42
  hashLines,
37
43
  lineNumbers: !raw && (hashLines || settings.get("readLineNumbers") === true),
@@ -273,7 +273,7 @@ export const handleMastodon: SpecialHandler = async (
273
273
  md += `### ${formatDate(status.created_at)}\n\n`;
274
274
  const content = await htmlToBasicMarkdown(status.content);
275
275
  md += `${content}\n\n`;
276
- md += `\uD83D\uDCAC ${status.replies_count} \u00B7 \uD83D\uDD01 ${status.reblogs_count} \u00B7 \u2B50 ${status.favourites_count}\n\n`;
276
+ md += `💬 ${status.replies_count} · 🔁 ${status.reblogs_count} · ${status.favourites_count}\n\n`;
277
277
  }
278
278
  }
279
279
  }
@@ -32,19 +32,19 @@ interface RepologyPackage {
32
32
  function statusIndicator(status: string): string {
33
33
  switch (status) {
34
34
  case "newest":
35
- return "\u2705"; // green check
35
+ return ""; // green check
36
36
  case "devel":
37
- return "\uD83D\uDEA7"; // construction
37
+ return "🚧"; // construction
38
38
  case "unique":
39
- return "\uD83D\uDD35"; // blue circle
39
+ return "🔵"; // blue circle
40
40
  case "outdated":
41
- return "\uD83D\uDD34"; // red circle
41
+ return "🔴"; // red circle
42
42
  case "legacy":
43
- return "\u26A0\uFE0F"; // warning
43
+ return "⚠\uFE0F"; // warning
44
44
  case "rolling":
45
- return "\uD83D\uDD04"; // arrows
45
+ return "🔄"; // arrows
46
46
  default:
47
- return "\u2796"; // minus
47
+ return ""; // minus
48
48
  }
49
49
  }
50
50
 
@@ -1,119 +0,0 @@
1
- import type { AsyncJobManager } from "../async";
2
- import { formatDuration } from "../tools/render-utils";
3
- import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
4
-
5
- export interface JobsProtocolOptions {
6
- getAsyncJobManager: () => AsyncJobManager | undefined;
7
- }
8
-
9
- function formatJobTime(startTime: number): string {
10
- return new Date(startTime).toISOString();
11
- }
12
-
13
- function formatJobDuration(startTime: number): string {
14
- return formatDuration(Math.max(0, Date.now() - startTime));
15
- }
16
-
17
- function normalizeJobId(url: InternalUrl): string {
18
- const host = url.rawHost || url.hostname;
19
- const pathname = (url.rawPathname ?? url.pathname).replace(/^\/+/, "").trim();
20
- if (host && pathname) return `${host}/${pathname}`;
21
- if (host) return host;
22
- return pathname;
23
- }
24
-
25
- export class JobsProtocolHandler implements ProtocolHandler {
26
- readonly scheme = "jobs";
27
-
28
- constructor(private readonly options: JobsProtocolOptions) {}
29
-
30
- async resolve(url: InternalUrl): Promise<InternalResource> {
31
- const manager = this.options.getAsyncJobManager();
32
- if (!manager) {
33
- const content =
34
- "# Jobs\n\nBackground job support is disabled. Enable `async.enabled` or `bash.autoBackground.enabled` to use jobs://.";
35
- return {
36
- url: url.href,
37
- content,
38
- contentType: "text/markdown",
39
- size: Buffer.byteLength(content, "utf-8"),
40
- };
41
- }
42
-
43
- const jobId = normalizeJobId(url);
44
- if (!jobId) {
45
- return this.#listJobs(url, manager);
46
- }
47
-
48
- return this.#getJob(url, manager, jobId);
49
- }
50
-
51
- #listJobs(url: InternalUrl, manager: AsyncJobManager): InternalResource {
52
- const jobs = manager.getAllJobs();
53
- const running = jobs.filter(job => job.status === "running").sort((a, b) => a.startTime - b.startTime);
54
- const done = jobs.filter(job => job.status !== "running").sort((a, b) => b.startTime - a.startTime);
55
- const ordered = [...running, ...done];
56
-
57
- if (ordered.length === 0) {
58
- const content = "# Jobs\n\nNo background jobs found.";
59
- return {
60
- url: url.href,
61
- content,
62
- contentType: "text/markdown",
63
- size: Buffer.byteLength(content, "utf-8"),
64
- };
65
- }
66
-
67
- const lines = ordered.map(job => {
68
- return `- \`${job.id}\` [${job.type}] **${job.status}** — ${job.label} \n started: ${formatJobTime(job.startTime)} · duration: ${formatJobDuration(job.startTime)}`;
69
- });
70
- const content = `# Jobs\n\n${ordered.length} job${ordered.length === 1 ? "" : "s"}\n\n${lines.join("\n")}`;
71
- return {
72
- url: url.href,
73
- content,
74
- contentType: "text/markdown",
75
- size: Buffer.byteLength(content, "utf-8"),
76
- };
77
- }
78
-
79
- #getJob(url: InternalUrl, manager: AsyncJobManager, jobId: string): InternalResource {
80
- const job = manager.getJob(jobId);
81
- if (!job) {
82
- const content = `# Job Not Found\n\n404: No async job found with id \`${jobId}\`.`;
83
- return {
84
- url: url.href,
85
- content,
86
- contentType: "text/markdown",
87
- size: Buffer.byteLength(content, "utf-8"),
88
- };
89
- }
90
-
91
- const sections = [
92
- `# Job ${job.id}`,
93
- "",
94
- `- type: ${job.type}`,
95
- `- status: ${job.status}`,
96
- `- label: ${job.label}`,
97
- `- start: ${formatJobTime(job.startTime)}`,
98
- `- duration: ${formatJobDuration(job.startTime)}`,
99
- ];
100
-
101
- if (job.status === "completed" && job.resultText) {
102
- sections.push("", "## Result", "", "```", job.resultText, "```");
103
- }
104
- if (job.status === "failed" && job.errorText) {
105
- sections.push("", "## Error", "", "```", job.errorText, "```");
106
- }
107
- if (job.status === "cancelled" && job.errorText) {
108
- sections.push("", "## Cancellation", "", "```", job.errorText, "```");
109
- }
110
-
111
- const content = sections.join("\n");
112
- return {
113
- url: url.href,
114
- content,
115
- contentType: "text/markdown",
116
- size: Buffer.byteLength(content, "utf-8"),
117
- };
118
- }
119
- }
@@ -1,47 +0,0 @@
1
- import { prompt } from "@oh-my-pi/pi-utils";
2
- import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.md" with { type: "text" };
3
- import { getTaskSimpleModeCapabilities, type TaskSimpleMode } from "./simple-mode";
4
- import type { TaskItem } from "./types";
5
-
6
- interface RenderResult {
7
- /** Full task text sent to the subagent */
8
- task: string;
9
- /** Raw per-task assignment text, without prompt template boilerplate */
10
- assignment: string;
11
- id: string;
12
- description: string;
13
- }
14
-
15
- /**
16
- * Build the full task text from shared context and per-task assignment.
17
- *
18
- * If context is provided, it is prepended with a separator.
19
- */
20
- export function renderTemplate(
21
- context: string | undefined,
22
- task: TaskItem,
23
- simpleMode: TaskSimpleMode = "default",
24
- ): RenderResult {
25
- let { id, description, assignment } = task;
26
- assignment = assignment.trim();
27
- const { contextEnabled } = getTaskSimpleModeCapabilities(simpleMode);
28
- context = contextEnabled ? context?.trim() : undefined;
29
-
30
- if (!context || !assignment) {
31
- if (simpleMode === "independent" && assignment) {
32
- return {
33
- task: prompt.render(subagentUserPromptTemplate, { assignment, independentMode: true }),
34
- assignment,
35
- id,
36
- description,
37
- };
38
- }
39
- return { task: assignment || context!, assignment: assignment || context!, id, description };
40
- }
41
- return {
42
- task: prompt.render(subagentUserPromptTemplate, { context, assignment, independentMode: false }),
43
- assignment,
44
- id,
45
- description,
46
- };
47
- }
@@ -1,107 +0,0 @@
1
- /**
2
- * Bash command normalizer - extracts patterns that are better handled natively.
3
- *
4
- * Detects and extracts:
5
- * - `| head -n N` / `| head -N` - extracted to headLines
6
- * - `| tail -n N` / `| tail -N` - extracted to tailLines
7
- */
8
-
9
- export interface NormalizedCommand {
10
- /** Cleaned command with patterns stripped */
11
- command: string;
12
- /** Extracted head line count, if any */
13
- headLines?: number;
14
- /** Extracted tail line count, if any */
15
- tailLines?: number;
16
- }
17
-
18
- /**
19
- * Pattern to match trailing pipe to head/tail.
20
- * Captures: full match, command (head/tail), line count
21
- *
22
- * Matches:
23
- * - `| head -n 50`
24
- * - `| head -50`
25
- * - `| tail -n 100`
26
- * - `| tail -100`
27
- *
28
- * Does NOT match head/tail with other flags or without line count.
29
- */
30
- const TRAILING_HEAD_TAIL_PATTERN = /\|\s*(head|tail)\s+(?:-n\s*(\d+)|(-\d+))\s*$/;
31
-
32
- /**
33
- * Normalize a bash command by stripping patterns better handled natively.
34
- *
35
- * Extracts `| head -n N` and `| tail -n N` suffixes into separate fields
36
- * so they can be applied post-execution without breaking streaming.
37
- *
38
- * Strips `2>&1` since we already merge stdout/stderr.
39
- */
40
- export function normalizeBashCommand(command: string): NormalizedCommand {
41
- let normalized = command;
42
- let headLines: number | undefined;
43
- let tailLines: number | undefined;
44
-
45
- // Extract trailing head/tail
46
- const match = normalized.match(TRAILING_HEAD_TAIL_PATTERN);
47
- if (match) {
48
- const [fullMatch, cmd, nValue, dashValue] = match;
49
- const lineCount = nValue ? Number.parseInt(nValue, 10) : Number.parseInt(dashValue.slice(1), 10);
50
-
51
- if (cmd === "head") {
52
- headLines = lineCount;
53
- } else {
54
- tailLines = lineCount;
55
- }
56
-
57
- normalized = normalized.slice(0, -fullMatch.length);
58
- }
59
-
60
- // Preserve internal whitespace (important for heredocs / indentation-sensitive scripts)
61
- normalized = normalized.trim();
62
-
63
- return {
64
- command: normalized,
65
- headLines,
66
- tailLines,
67
- };
68
- }
69
-
70
- /**
71
- * Apply head/tail limits to output text.
72
- *
73
- * If both head and tail are specified, head is applied first (take first N lines),
74
- * then tail is applied (take last M lines of that).
75
- */
76
- export function applyHeadTail(
77
- text: string,
78
- headLines?: number,
79
- tailLines?: number,
80
- ): { text: string; applied: boolean; headApplied?: number; tailApplied?: number } {
81
- if (!headLines && !tailLines) {
82
- return { text, applied: false };
83
- }
84
-
85
- let lines = text.split("\n");
86
- let headApplied: number | undefined;
87
- let tailApplied: number | undefined;
88
-
89
- // Apply head first (keep first N lines)
90
- if (headLines !== undefined && headLines > 0 && lines.length > headLines) {
91
- lines = lines.slice(0, headLines);
92
- headApplied = headLines;
93
- }
94
-
95
- // Then apply tail (keep last N lines)
96
- if (tailLines !== undefined && tailLines > 0 && lines.length > tailLines) {
97
- lines = lines.slice(-tailLines);
98
- tailApplied = tailLines;
99
- }
100
-
101
- return {
102
- text: lines.join("\n"),
103
- applied: headApplied !== undefined || tailApplied !== undefined,
104
- headApplied,
105
- tailApplied,
106
- };
107
- }