@selesai/code 0.13.35 → 0.13.38

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.
@@ -0,0 +1,190 @@
1
+ /**
2
+ * jev-find-source — the source units `jev_find` asks Jev about and returns verbatim.
3
+ *
4
+ * A unit is a line range of one file. JavaScript and TypeScript files are split with the
5
+ * TypeScript parser into top-level statements (consecutive imports merged, and a class or
6
+ * interface longer than FIND_CHUNK_LINES split into its members, each keeping the header line),
7
+ * each with the comments directly above it. Every other file, and a script file when the parser
8
+ * is not installed, is split into blank-line-separated chunks of at most FIND_CHUNK_LINES lines.
9
+ * Line numbers are 1-based and always the file's own: excerpts are cut from the file, never
10
+ * reconstructed.
11
+ */
12
+ import { extname } from "node:path";
13
+ import type * as TypeScript from "typescript";
14
+
15
+ type TypeScriptApi = typeof TypeScript;
16
+
17
+ /** ponytail: 40-line chunks for non-script files; raise once Jev is measured on longer units. */
18
+ export const FIND_CHUNK_LINES = 40;
19
+ const UNIT_NAME_CHARS = 40;
20
+
21
+ export interface SourceUnit {
22
+ /** What the unit is: a declaration name, `Class.member`, `imports`, or the chunk's first line. */
23
+ name: string;
24
+ /** First line, 1-based, inclusive. */
25
+ start: number;
26
+ /** Last line, 1-based, inclusive. */
27
+ end: number;
28
+ /** Header line of the enclosing class or interface, kept with a member unit. */
29
+ header?: number;
30
+ }
31
+
32
+ let typeScript: Promise<TypeScriptApi | undefined> | undefined;
33
+
34
+ /**
35
+ * The TypeScript compiler, or undefined when it is not installed: it is a devDependency, so a
36
+ * published install parses nothing and every file is chunked instead.
37
+ */
38
+ export function loadTypeScript(): Promise<TypeScriptApi | undefined> {
39
+ typeScript ??= import("typescript").then(
40
+ (module: unknown) => {
41
+ const api = ((module as { default?: TypeScriptApi }).default ?? module) as TypeScriptApi;
42
+ return typeof api.createSourceFile === "function" ? api : undefined;
43
+ },
44
+ () => undefined,
45
+ );
46
+ return typeScript;
47
+ }
48
+
49
+ const SCRIPT_KINDS: Record<string, "TS" | "TSX" | "JS" | "JSX"> = {
50
+ ".ts": "TS",
51
+ ".tsx": "TSX",
52
+ ".js": "JS",
53
+ ".jsx": "JSX",
54
+ ".mjs": "JS",
55
+ ".cjs": "JS",
56
+ };
57
+
58
+ /** Whether `path` is split with the TypeScript parser (when it is installed). */
59
+ export function isScriptPath(path: string): boolean {
60
+ return extname(path).toLowerCase() in SCRIPT_KINDS;
61
+ }
62
+
63
+ /** A file's source units in file order; `ts` undefined chunks every file. */
64
+ export function splitSourceUnits(path: string, text: string, ts: TypeScriptApi | undefined): SourceUnit[] {
65
+ const kind = SCRIPT_KINDS[extname(path).toLowerCase()];
66
+ if (ts && kind) {
67
+ try {
68
+ const units = scriptUnits(ts, path, text, ts.ScriptKind[kind]);
69
+ if (units.length > 0) return units;
70
+ } catch {
71
+ // A parser crash on odd input falls back to chunks; the file is still readable.
72
+ }
73
+ }
74
+ return chunkUnits(text);
75
+ }
76
+
77
+ function firstLine(text: string): string {
78
+ const line = text.split("\n").find((row) => row.trim() !== "") ?? "";
79
+ return line.trim().slice(0, UNIT_NAME_CHARS);
80
+ }
81
+
82
+ function scriptUnits(ts: TypeScriptApi, path: string, text: string, kind: TypeScript.ScriptKind): SourceUnit[] {
83
+ const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, kind);
84
+ const lineOf = (pos: number) => source.getLineAndCharacterOfPosition(pos).line + 1;
85
+ /**
86
+ * A node's first line, counting the comments directly above it: a comment separated by a blank
87
+ * line (a section banner) or sitting on the previous node's last line (`a(); // note`) is not its own.
88
+ */
89
+ const startOf = (node: TypeScript.Node, floor: number): number => {
90
+ let start = lineOf(node.getStart(source));
91
+ const comments = ts.getLeadingCommentRanges(text, node.pos) ?? [];
92
+ for (let i = comments.length - 1; i >= 0; i -= 1) {
93
+ const comment = comments[i];
94
+ const from = lineOf(comment.pos);
95
+ if (from <= floor || lineOf(comment.end) < start - 1) break;
96
+ start = from;
97
+ }
98
+ return start;
99
+ };
100
+ const nameOf = (node: TypeScript.Node): string => {
101
+ const named = (node as { name?: TypeScript.Node }).name;
102
+ if (named) return named.getText(source);
103
+ if (ts.isVariableStatement(node)) {
104
+ return node.declarationList.declarations.map((declaration) => declaration.name.getText(source)).join(", ");
105
+ }
106
+ if (ts.isConstructorDeclaration(node)) return "constructor";
107
+ if (ts.isExportAssignment(node)) return "export default";
108
+ return firstLine(node.getText(source));
109
+ };
110
+
111
+ const units: SourceUnit[] = [];
112
+ let floor = 0;
113
+ for (const statement of source.statements) {
114
+ const start = startOf(statement, floor);
115
+ const end = lineOf(statement.end);
116
+ floor = end;
117
+ if (ts.isImportDeclaration(statement) || ts.isImportEqualsDeclaration(statement)) {
118
+ const last = units.at(-1);
119
+ if (last?.name === "imports" && last.end >= start - 1) last.end = end;
120
+ else units.push({ name: "imports", start, end });
121
+ continue;
122
+ }
123
+ const container =
124
+ ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement) ? statement : undefined;
125
+ const members = container?.members;
126
+ if (container && members && members.length > 0 && end - start + 1 > FIND_CHUNK_LINES) {
127
+ const owner = nameOf(container);
128
+ const header = lineOf((container.name ?? container).getStart(source));
129
+ let memberFloor = header;
130
+ for (const member of members) {
131
+ const memberStart = startOf(member, memberFloor);
132
+ const memberEnd = lineOf(member.end);
133
+ memberFloor = memberEnd;
134
+ units.push({
135
+ name: `${owner}.${nameOf(member)}`,
136
+ start: memberStart,
137
+ end: memberEnd,
138
+ ...(memberStart > header ? { header } : {}),
139
+ });
140
+ }
141
+ continue;
142
+ }
143
+ units.push({ name: nameOf(statement), start, end });
144
+ }
145
+ return units;
146
+ }
147
+
148
+ /** Blank-line-separated paragraphs packed into chunks of at most FIND_CHUNK_LINES lines. */
149
+ export function chunkUnits(text: string): SourceUnit[] {
150
+ const rows = text.split("\n");
151
+ const paragraphs: Array<{ start: number; end: number }> = [];
152
+ rows.forEach((row, index) => {
153
+ if (row.trim() === "") return;
154
+ const last = paragraphs.at(-1);
155
+ if (last && last.end === index) last.end = index + 1;
156
+ else paragraphs.push({ start: index + 1, end: index + 1 });
157
+ });
158
+ const ranges: Array<{ start: number; end: number }> = [];
159
+ for (const paragraph of paragraphs) {
160
+ const current = ranges.at(-1);
161
+ if (current && paragraph.end - current.start + 1 <= FIND_CHUNK_LINES) {
162
+ current.end = paragraph.end;
163
+ continue;
164
+ }
165
+ // A paragraph longer than a chunk is cut into chunk-sized pieces.
166
+ for (let start = paragraph.start; start <= paragraph.end; start += FIND_CHUNK_LINES) {
167
+ ranges.push({ start, end: Math.min(paragraph.end, start + FIND_CHUNK_LINES - 1) });
168
+ }
169
+ }
170
+ return ranges.map((range) => ({ ...range, name: firstLine(rows[range.start - 1]) }));
171
+ }
172
+
173
+ /**
174
+ * Line windows around the best keyword lines (±`context` lines), merged where they overlap: the
175
+ * source returned when Jev could not judge a file's units. With no keyword line, the file's head.
176
+ */
177
+ export function keywordWindows(lineCount: number, hits: readonly number[], context = 3, maxWindows = 3): SourceUnit[] {
178
+ if (lineCount <= 0) return [];
179
+ const anchors = [...new Set(hits.filter((line) => line >= 1 && line <= lineCount))].slice(0, maxWindows);
180
+ if (anchors.length === 0) return [{ name: "file head", start: 1, end: Math.min(lineCount, 2 * context + 1) }];
181
+ const windows: SourceUnit[] = [];
182
+ for (const line of anchors.sort((a, b) => a - b)) {
183
+ const start = Math.max(1, line - context);
184
+ const end = Math.min(lineCount, line + context);
185
+ const last = windows.at(-1);
186
+ if (last && start <= last.end + 1) last.end = Math.max(last.end, end);
187
+ else windows.push({ name: "keyword window", start, end });
188
+ }
189
+ return windows;
190
+ }
@@ -24,9 +24,9 @@
24
24
  "./tokenin-onboarding.ts",
25
25
  "./pi-graft",
26
26
  "./undo.ts",
27
+ "./pi-web-agent",
27
28
  "./pi-subagents",
28
29
  "./pi-tool-display",
29
- "./pi-web-agent",
30
30
  "./pi-hermes-memory",
31
31
  "./tps.ts",
32
32
  "./capability-gateway",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: researcher
3
3
  description: Autonomous web researcher — searches, evaluates, and synthesizes a focused research brief
4
- tools: read, write, web_search, fetch_content, get_search_content, source_check
4
+ tools: read, write, web_explore
5
5
  thinking: medium
6
6
  systemPromptMode: replace
7
7
  inheritProjectContext: true
@@ -16,11 +16,10 @@ Given a question or topic, run focused web research and produce a concise, well-
16
16
 
17
17
  Working rules:
18
18
  - Break the problem into 2-4 distinct research angles.
19
- - Use `web_search` with `queries` so the search covers multiple angles instead of one generic query. Use `workflow: "none"` unless the task explicitly needs the interactive curator.
20
- - Treat search-result summaries as discovery aids, not final evidence for important claims. Fetch the original source when a claim is important, disputed, surprising, or decision-relevant.
19
+ - Use `web_explore` once per research angle with a focused `query`; it handles search, fetch, and source ranking internally. Call it again with a narrower query to fill gaps.
20
+ - Treat search-result summaries as discovery aids, not final evidence for important claims. Re-check important, disputed, surprising, or decision-relevant claims against the original source with a narrower `web_explore` query.
21
21
  - Prefer primary, official, authoritative, or directly relevant sources. Keep a smaller set of strong sources rather than many weak or redundant ones; reject stale, redundant, or SEO-heavy sources, and flag stale evidence when freshness materially affects the answer.
22
- - Use `source_check` against fetched source content for decision-critical or disputed claims, benchmark/performance claims, pricing/licensing claims, security claims, and wording that could materially affect a recommendation. Do not use it for every trivial fact.
23
- - `source_check` must be registered by the loaded provider before launch. If a registered `source_check` call fails, continue by fetching and inspecting the original source directly, and disclose the validation limitation rather than failing the research run.
22
+ - Verify decision-critical or disputed claims, benchmark/performance claims, pricing/licensing claims, security claims, and wording that could materially affect a recommendation against the original source. Do not do this for every trivial fact. Disclose any validation limitation rather than failing the research run.
24
23
  - Label direct evidence, source interpretation, and researcher inference distinctly. Never present an inference as if the source stated it directly.
25
24
  - Record contradictions instead of silently resolving them. Record missing evidence when a claim cannot be verified.
26
25
  - Never invent dates, quotations, citations, or unsupported precision.
@@ -73,13 +73,7 @@ Native `oracle` runs inside Pi and can use its configured read tools. The Claude
73
73
  | `external-job-requests/` and `external-job-responses/` | Host-mediated provider bridge | pending request, terminal response | Host process writes a matching response and removes the request | Bridge timeout or malformed request response | Requests are operation-scoped. Recovery sends `reattach`/`result`, not `start` or `follow-up`, when job metadata exists. `start` and `follow-up` use durable dispatch claims | Provider not registered, host bridge not loaded, malformed request, provider exception, ambiguous dispatch without a provider job id |
74
74
  | Provider artifact path | External provider | provider-defined terminal artifact | Provider returns `artifactPath`, or Pi writes returned text to `external-job-<index>.result.md` | Provider reports failure or no result | Existing artifact path is retained in `status.json` | Missing artifact with no text output returns a terminal message instead of inventing content |
75
75
 
76
- The `researcher` builtin uses `web_search`, `fetch_content`, `get_search_content`, and selective `source_check` validation. Those require [pi-web-access](https://github.com/nicobailon/pi-web-access):
77
-
78
- ```bash
79
- pi install npm:pi-web-access
80
- ```
81
-
82
- The loaded provider must register all four tools, including `source_check`, before launch; a missing required tool prevents a successful run. Fetched-source inspection is a fallback for a registered `source_check` call failing, not for missing registration.
76
+ The `researcher` builtin uses `web_explore` from the bundled `pi-web-agent` extension. Because the `tools` field is a strict allowlist, `web_explore` must be registered in the child; run `researcher` as a background child (`async: true`) so ambient extensions load.
83
77
 
84
78
  ## Overriding builtins and custom agents
85
79
 
@@ -1904,7 +1904,7 @@ Do work
1904
1904
  delegate: ["read", "grep", "find", "ls", "bash", "edit", "write", "contact_supervisor"],
1905
1905
  reviewer: ["read", "grep", "find", "ls", "contact_supervisor"],
1906
1906
  scout: ["read", "grep", "find", "ls", "bash", "write", "contact_supervisor"],
1907
- researcher: ["read", "write", "web_search", "fetch_content", "get_search_content", "source_check"],
1907
+ researcher: ["read", "write", "web_explore"],
1908
1908
  };
1909
1909
  for (const [name, tools] of Object.entries(expectedTools)) {
1910
1910
  const agent = agents.find((candidate) => candidate.name === name);
@@ -1914,12 +1914,11 @@ Do work
1914
1914
 
1915
1915
  const researcherPrompt = agents.find((candidate) => candidate.name === "researcher")?.systemPrompt ?? "";
1916
1916
  assert.match(researcherPrompt, /search-result summaries as discovery aids, not final evidence/);
1917
- assert.match(researcherPrompt, /source_check.*decision-critical or disputed claims/);
1917
+ assert.match(researcherPrompt, /Verify decision-critical or disputed claims/);
1918
1918
  assert.match(researcherPrompt, /direct evidence, source interpretation, and researcher inference distinctly/);
1919
1919
  assert.match(researcherPrompt, /Record contradictions.*Record missing evidence/);
1920
1920
  assert.match(researcherPrompt, /Never invent dates, quotations, citations, or unsupported precision/);
1921
- assert.match(researcherPrompt, /`source_check` must be registered by the loaded provider before launch/);
1922
- assert.match(researcherPrompt, /If a registered `source_check` call fails, continue/);
1921
+ assert.match(researcherPrompt, /`web_explore`/);
1923
1922
  assert.match(researcherPrompt, /\*\*Support:\*\* direct evidence \| interpretation\. \*\*Confidence:\*\* high \| medium \| low/);
1924
1923
  } finally {
1925
1924
  if (previousHome === undefined) delete process.env.HOME;
@@ -1,3 +1,4 @@
1
+ import { fileURLToPath } from 'node:url';
1
2
  import type { ExtensionAPI } from '@selesai/code';
2
3
  import { getDefaultBackendConfig, type BackendConfig } from './backends/config.js';
3
4
  import { Type } from 'typebox';
@@ -71,6 +72,16 @@ async function getEffectiveBackendConfig(pi: ExtensionAPI): Promise<BackendConfi
71
72
  export default function extension(pi: ExtensionAPI) {
72
73
  registerWebAgentConfigCommands(pi);
73
74
 
75
+ // Lets foreground `researcher` children load this extension (see pi-subagents builtin augmentations).
76
+ pi.events.on('pi-subagents:request-builtin-agent-augmentations', (request: unknown) => {
77
+ (request as { register?: (a: unknown) => void } | undefined)?.register?.({
78
+ id: 'pi-web-agent',
79
+ agentNames: ['researcher'],
80
+ addTools: ['web_explore'],
81
+ subagentOnlyExtensions: [fileURLToPath(import.meta.url)]
82
+ });
83
+ });
84
+
74
85
  const injectedWebExplore = (pi as ExtensionAPI & { __webExploreTool?: ReturnType<typeof createWebExploreTool> }).__webExploreTool;
75
86
  let cachedBackendKey: string | undefined;
76
87
  let cachedWebExplore: ReturnType<typeof createWebExploreTool> | undefined;
@@ -68,6 +68,12 @@ export default function ponytailExtension(pi) {
68
68
 
69
69
  currentMode = normalized;
70
70
  pi.appendEntry("ponytail-mode", { mode: normalized });
71
+ // Remember the choice for future sessions (new chats, handoffs) too.
72
+ try {
73
+ if (writeDefaultMode(normalized)) configuredDefaultMode = getDefaultMode();
74
+ } catch (_) {
75
+ // ponytail: unwritable config just means this session-only; not worth failing the command.
76
+ }
71
77
  ctx?.ui?.notify?.(`Ponytail mode set to ${normalized}.`, "info");
72
78
  };
73
79
 
@@ -168,9 +174,6 @@ export default function ponytailExtension(pi) {
168
174
  // Guard a null/undefined event or a missing systemPrompt: don't crash, and
169
175
  // don't prepend the literal string "undefined" to the prompt (#439, #440).
170
176
  const base = event?.systemPrompt ? `${event.systemPrompt}\n\n` : "";
171
- // ponytail: full keeps the detailed skill; alternate modes use the compact
172
- // ruleset to avoid repeating the same system prompt on every request.
173
- const compact = currentMode !== "full";
174
- return { systemPrompt: `${base}${getPonytailInstructions(currentMode, { compact })}` };
177
+ return { systemPrompt: `${base}${getPonytailInstructions(currentMode)}` };
175
178
  });
176
179
  }
@@ -3,19 +3,20 @@
3
3
  //
4
4
  // Resolution order for default mode:
5
5
  // 1. PONYTAIL_DEFAULT_MODE environment variable
6
- // 2. Config file defaultMode field:
7
- // - $XDG_CONFIG_HOME/ponytail/config.json (any platform, if set)
8
- // - ~/.config/ponytail/config.json (macOS / Linux fallback)
9
- // - %APPDATA%\ponytail\config.json (Windows fallback)
10
- // 3. 'full'
6
+ // 2. "ponytailMode" in the agent settings.json
7
+ // ($SELESAI_CODING_AGENT_DIR/settings.json, else ~/.selesai/agent/settings.json)
8
+ // 3. 'lite' (built-in default)
9
+ //
10
+ // quietStartup still lives in the standalone ponytail config.json (getConfigPath).
11
11
 
12
12
  const fs = require('fs');
13
13
  const path = require('path');
14
14
  const os = require('os');
15
15
 
16
- const DEFAULT_MODE = 'full';
17
- const VALID_MODES = ['off', 'lite', 'full', 'ultra', 'review'];
18
- const RUNTIME_MODES = ['off', 'lite', 'full', 'ultra'];
16
+ const DEFAULT_MODE = 'lite';
17
+ const VALID_MODES = ['off', 'lite', 'full', 'review'];
18
+ const RUNTIME_MODES = ['off', 'lite', 'full'];
19
+ const SETTINGS_KEY = 'ponytailMode';
19
20
 
20
21
  function normalizeMode(mode) {
21
22
  if (typeof mode !== 'string') return null;
@@ -73,26 +74,49 @@ function getClaudeDir() {
73
74
  return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
74
75
  }
75
76
 
77
+ // ponytail: mirrors getAgentDir() in src/config.ts with the "selesai" app name
78
+ // hardcoded (this CJS file can't import the TS config). Revisit if the fork is renamed.
79
+ function getSettingsPath() {
80
+ const envDir = process.env.SELESAI_CODING_AGENT_DIR;
81
+ const agentDir = envDir
82
+ ? envDir.replace(/^~(?=$|[\\/])/, os.homedir())
83
+ : path.join(os.homedir(), '.selesai', 'agent');
84
+ return path.join(agentDir, 'settings.json');
85
+ }
86
+
87
+ // Returns {} when the file is missing; throws on unreadable/invalid JSON so a
88
+ // write never replaces a settings file we couldn't parse.
89
+ function readSettings() {
90
+ let raw;
91
+ try {
92
+ raw = fs.readFileSync(getSettingsPath(), 'utf8');
93
+ } catch (e) {
94
+ if (e.code === 'ENOENT') return {};
95
+ throw e;
96
+ }
97
+ const parsed = JSON.parse(raw.replace(/^\uFEFF/, ''));
98
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
99
+ throw new Error('settings.json is not a JSON object');
100
+ }
101
+ return parsed;
102
+ }
103
+
76
104
  function getDefaultMode() {
77
105
  // 1. Environment variable (highest priority)
78
106
  const envMode = process.env.PONYTAIL_DEFAULT_MODE;
79
- // ponytail: a default must be a runtime level (off/lite/full/ultra); review is
107
+ // ponytail: a default must be a runtime level (off/lite/full); review is
80
108
  // a session-only mode, never a valid default (#377). Validate against
81
109
  // RUNTIME_MODES so a stray env var or config can't make review the default.
82
110
  if (envMode && RUNTIME_MODES.includes(envMode.toLowerCase())) {
83
111
  return envMode.toLowerCase();
84
112
  }
85
113
 
86
- // 2. Config file
114
+ // 2. settings.json
87
115
  try {
88
- const configPath = getConfigPath();
89
- // Strip UTF-8 BOM (common on Windows-saved files) so JSON.parse doesn't choke
90
- const config = JSON.parse(fs.readFileSync(configPath, 'utf8').replace(/^\uFEFF/, ''));
91
- if (config.defaultMode && RUNTIME_MODES.includes(config.defaultMode.toLowerCase())) {
92
- return config.defaultMode.toLowerCase();
93
- }
116
+ const mode = normalizeMode(readSettings()[SETTINGS_KEY]);
117
+ if (mode) return mode;
94
118
  } catch (e) {
95
- // Config file doesn't exist or is invalid — fall through
119
+ // Unreadable or invalid settings — fall through
96
120
  }
97
121
 
98
122
  // 3. Default
@@ -121,15 +145,16 @@ function writeDefaultMode(mode) {
121
145
  const normalized = normalizeMode(mode);
122
146
  if (!normalized) return null;
123
147
 
124
- const configPath = getConfigPath();
125
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
126
- let config = {};
127
- try {
128
- config = JSON.parse(fs.readFileSync(configPath, 'utf8').replace(/^\uFEFF/, ''));
129
- if (!config || typeof config !== 'object' || Array.isArray(config)) config = {};
130
- } catch (_) {}
131
- config.defaultMode = normalized;
132
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
148
+ // Keep every other key; readSettings throws rather than letting us clobber a
149
+ // file we couldn't parse.
150
+ // ponytail: no lock shared with SettingsManager. Its saves re-read the file and
151
+ // merge, so our key survives; only a same-millisecond concurrent write could race.
152
+ const settingsPath = getSettingsPath();
153
+ const settings = readSettings();
154
+ if (settings[SETTINGS_KEY] === normalized) return normalized;
155
+ settings[SETTINGS_KEY] = normalized;
156
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
157
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
133
158
  return normalized;
134
159
  }
135
160
 
@@ -140,6 +165,7 @@ module.exports = {
140
165
  getDefaultMode,
141
166
  getConfigDir,
142
167
  getConfigPath,
168
+ getSettingsPath,
143
169
  getClaudeDir,
144
170
  getQuietStartup,
145
171
  isShellSafe,
@@ -6,11 +6,6 @@ const path = require('path');
6
6
  const { DEFAULT_MODE, normalizeMode, normalizePersistedMode } = require('./ponytail-config.cjs');
7
7
 
8
8
  const INDEPENDENT_MODES = new Set(['review']);
9
- const COMPACT_MODE_RULES = {
10
- lite: 'Build the ask; name a lazier alternative.',
11
- full: 'Enforce ladder; shortest diff.',
12
- ultra: 'YAGNI first; delete; challenge the rest.',
13
- };
14
9
  const SKILL_PATH = path.join(__dirname, '..', '..', 'skills', 'ponytail', 'SKILL.md');
15
10
 
16
11
  function filterSkillBodyForMode(body, mode) {
@@ -18,7 +13,7 @@ function filterSkillBodyForMode(body, mode) {
18
13
  const withoutFrontmatter = String(body || '').replace(/^---[\s\S]*?---\s*/, '');
19
14
 
20
15
  // Only the intensity table rows and worked examples are mode-specific, and
21
- // both are keyed by a mode name (lite/full/ultra). A bullet whose label is
16
+ // both are keyed by a mode name (lite/full). A bullet whose label is
22
17
  // not a mode — e.g. "No unrequested abstractions: ..." — is a normal rule
23
18
  // and must be kept verbatim.
24
19
  return withoutFrontmatter
@@ -50,7 +45,7 @@ function getFallbackInstructions(mode) {
50
45
  'You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.\n\n' +
51
46
  '## Persistence\n\n' +
52
47
  'ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if unsure. Off only: "stop ponytail" / "normal mode".\n\n' +
53
- 'Current level: **' + mode + '**. Switch: `/ponytail lite|full|ultra`.\n\n' +
48
+ 'Current level: **' + mode + '**. Switch: `/ponytail off|lite|full`.\n\n' +
54
49
  '## The ladder\n\n' +
55
50
  'Before any code, stop at the first rung that holds (the ladder runs after you understand the problem, not instead of it — read the code it touches and trace the real flow first):\n' +
56
51
  '1. Does this need to be built at all? (YAGNI)\n' +
@@ -79,15 +74,7 @@ function getFallbackInstructions(mode) {
79
74
  'Ponytail governs what you build, not how you talk. "stop ponytail" or "normal mode": revert. Level persists until changed or session end.';
80
75
  }
81
76
 
82
- function getCompactInstructions(mode) {
83
- return 'PONYTAIL ' + mode.toUpperCase() + ': persistent every response; off only "stop ponytail"/"normal mode". ' +
84
- 'Trace first. Ladder: YAGNI -> reuse -> stdlib/native -> installed deps -> minimum diff. ' +
85
- 'Fix root cause, not caller symptom. No unrequested abstractions/deps/boilerplate; delete first. ' +
86
- 'Preserve validation, data-loss handling, security, accessibility, explicit asks. ' +
87
- 'Non-trivial logic: one runnable check. Code first; <=3 lines. ' + COMPACT_MODE_RULES[mode];
88
- }
89
-
90
- function getPonytailInstructions(mode, options) {
77
+ function getPonytailInstructions(mode) {
91
78
  const configuredMode = normalizePersistedMode(mode) || DEFAULT_MODE;
92
79
 
93
80
  if (INDEPENDENT_MODES.has(configuredMode)) {
@@ -95,7 +82,6 @@ function getPonytailInstructions(mode, options) {
95
82
  }
96
83
 
97
84
  const effectiveMode = normalizeMode(configuredMode) || DEFAULT_MODE;
98
- if (options?.compact === true) return getCompactInstructions(effectiveMode);
99
85
 
100
86
  try {
101
87
  return 'PONYTAIL MODE ACTIVE — level: ' + effectiveMode + '\n\n' +
@@ -42,13 +42,17 @@ function createCommandContext(overrides = {}) {
42
42
  function withTempConfig(fn) {
43
43
  const tempConfigHome = mkdtempSync(join(tmpdir(), "ponytail-test-"));
44
44
  const previousXdg = process.env.XDG_CONFIG_HOME;
45
+ const previousAgentDir = process.env.SELESAI_CODING_AGENT_DIR;
45
46
  process.env.XDG_CONFIG_HOME = tempConfigHome;
47
+ process.env.SELESAI_CODING_AGENT_DIR = tempConfigHome;
46
48
 
47
49
  return Promise.resolve()
48
50
  .then(fn)
49
51
  .finally(() => {
50
52
  if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME;
51
53
  else process.env.XDG_CONFIG_HOME = previousXdg;
54
+ if (previousAgentDir === undefined) delete process.env.SELESAI_CODING_AGENT_DIR;
55
+ else process.env.SELESAI_CODING_AGENT_DIR = previousAgentDir;
52
56
  rmSync(tempConfigHome, { recursive: true, force: true });
53
57
  });
54
58
  }
@@ -65,15 +69,32 @@ describe("ponytail extension", () => {
65
69
  const ctx = createCommandContext();
66
70
 
67
71
  await events.get("session_start")({ reason: "startup" }, ctx);
68
- await commands.get("ponytail").handler("ultra", ctx);
72
+ await commands.get("ponytail").handler("lite", ctx);
69
73
 
70
74
  expect(appendedEntries.at(-1)).toEqual({
71
75
  customType: "ponytail-mode",
72
- data: { mode: "ultra" },
76
+ data: { mode: "lite" },
73
77
  });
74
78
 
75
79
  const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
76
- expect(result.systemPrompt).toMatch(/^BASE\n\nPONYTAIL ULTRA:/);
80
+ expect(result.systemPrompt).toMatch(/^BASE\n\nPONYTAIL MODE ACTIVE — level: lite\n/);
81
+ }));
82
+
83
+ it("/ponytail <mode> carries into brand-new sessions", async () => withTempConfig(async () => {
84
+ const first = createPiHarness();
85
+ await first.events.get("session_start")({ reason: "startup" }, createCommandContext());
86
+ await first.commands.get("ponytail").handler("lite", createCommandContext());
87
+
88
+ // Fresh extension instance + empty session, like a new chat or handoff.
89
+ const next = createPiHarness();
90
+ await next.events.get("session_start")({ reason: "new" }, createCommandContext());
91
+ const result = await next.events.get("before_agent_start")({ systemPrompt: "BASE" }, createCommandContext());
92
+ expect(result.systemPrompt).toMatch(/PONYTAIL MODE ACTIVE — level: lite\n/);
93
+
94
+ await next.commands.get("ponytail").handler("off", createCommandContext());
95
+ const after = createPiHarness();
96
+ await after.events.get("session_start")({ reason: "new" }, createCommandContext());
97
+ expect(await after.events.get("before_agent_start")({ systemPrompt: "BASE" }, createCommandContext())).toBeUndefined();
77
98
  }));
78
99
 
79
100
  it("session_start restores latest persisted mode", async () => withTempConfig(async () => {
@@ -89,7 +110,7 @@ describe("ponytail extension", () => {
89
110
  await events.get("session_start")({ reason: "resume" }, ctx);
90
111
  const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
91
112
 
92
- expect(result.systemPrompt).toMatch(/PONYTAIL LITE:/);
113
+ expect(result.systemPrompt).toMatch(/PONYTAIL MODE ACTIVE — level: lite\n/);
93
114
  }));
94
115
 
95
116
  it("review mode retains its independent skill prompt", async () => withTempConfig(async () => {
@@ -140,7 +161,7 @@ describe("ponytail extension", () => {
140
161
  const ctx = createCommandContext();
141
162
 
142
163
  await events.get("session_start")({ reason: "startup" }, ctx);
143
- await commands.get("ponytail").handler("ultra", ctx);
164
+ await commands.get("ponytail").handler("lite", ctx);
144
165
  await events.get("input")({ text: "normal mode", source: "interactive" }, ctx);
145
166
 
146
167
  const disabled = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
@@ -152,11 +173,11 @@ describe("ponytail extension", () => {
152
173
  const ctx = createCommandContext();
153
174
 
154
175
  await events.get("session_start")({ reason: "startup" }, ctx);
155
- await commands.get("ponytail").handler("ultra", ctx);
176
+ await commands.get("ponytail").handler("lite", ctx);
156
177
  await events.get("input")({ text: "add a normal mode toggle next to dark mode", source: "interactive" }, ctx);
157
178
 
158
179
  const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
159
- expect(result.systemPrompt).toMatch(/PONYTAIL ULTRA:/);
180
+ expect(result.systemPrompt).toMatch(/PONYTAIL MODE ACTIVE — level: lite\n/);
160
181
  }));
161
182
 
162
183
  it("never writes a Ponytail statusline", async () => withTempConfig(async () => {
@@ -179,7 +200,7 @@ describe("ponytail extension", () => {
179
200
 
180
201
  await events.get("session_start")({ reason: "startup" }, ctx);
181
202
  await commands.get("ponytail").handler("status", ctx);
182
- expect(notifications.at(-1).msg).toContain("Ponytail: current full • default full");
203
+ expect(notifications.at(-1).msg).toContain("Ponytail: current lite • default lite");
183
204
  }));
184
205
 
185
206
  it("/ponytail set-default persists the default mode", async () => withTempConfig(async () => {
@@ -194,7 +215,7 @@ describe("ponytail extension", () => {
194
215
 
195
216
  it("/ponytail set-default with env override reports the override", async () => withTempConfig(async () => {
196
217
  const previous = process.env.PONYTAIL_DEFAULT_MODE;
197
- process.env.PONYTAIL_DEFAULT_MODE = "ultra";
218
+ process.env.PONYTAIL_DEFAULT_MODE = "full";
198
219
  try {
199
220
  const { commands, events } = createPiHarness();
200
221
  const notifications = [];
@@ -202,7 +223,7 @@ describe("ponytail extension", () => {
202
223
 
203
224
  await events.get("session_start")({ reason: "startup" }, ctx);
204
225
  await commands.get("ponytail").handler("default lite", ctx);
205
- expect(notifications.at(-1).msg).toContain("env override keeps default at ultra");
226
+ expect(notifications.at(-1).msg).toContain("env override keeps default at full");
206
227
  } finally {
207
228
  if (previous === undefined) delete process.env.PONYTAIL_DEFAULT_MODE;
208
229
  else process.env.PONYTAIL_DEFAULT_MODE = previous;
@@ -224,11 +245,11 @@ describe("ponytail extension", () => {
224
245
  const ctx = createCommandContext();
225
246
 
226
247
  await events.get("session_start")({ reason: "startup" }, ctx);
227
- await commands.get("ponytail").handler("ultra", ctx);
248
+ await commands.get("ponytail").handler("lite", ctx);
228
249
  await events.get("input")({ text: "normal mode", source: "extension" }, ctx);
229
250
 
230
251
  const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
231
- expect(result.systemPrompt).toMatch(/PONYTAIL ULTRA:/);
252
+ expect(result.systemPrompt).toMatch(/PONYTAIL MODE ACTIVE — level: lite\n/);
232
253
  }));
233
254
 
234
255
  it("setMode ignores invalid modes", async () => withTempConfig(async () => {
@@ -252,7 +273,7 @@ describe("ponytail extension", () => {
252
273
 
253
274
  await events.get("session_start")({ reason: "resume" }, ctx);
254
275
  const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
255
- expect(result.systemPrompt).toMatch(/PONYTAIL LITE:/);
276
+ expect(result.systemPrompt).toMatch(/PONYTAIL MODE ACTIVE — level: lite\n/);
256
277
  }));
257
278
 
258
279
  it("session_start tolerates a missing sessionManager", async () => withTempConfig(async () => {
@@ -261,7 +282,7 @@ describe("ponytail extension", () => {
261
282
 
262
283
  await events.get("session_start")({ reason: "resume" }, ctx);
263
284
  const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
264
- expect(result.systemPrompt).toMatch(/PONYTAIL MODE ACTIVE — level: full/);
285
+ expect(result.systemPrompt).toMatch(/PONYTAIL MODE ACTIVE — level: lite\n/); // default is lite
265
286
  }));
266
287
 
267
288
  it("input handler tolerates missing text", async () => withTempConfig(async () => {
@@ -269,11 +290,11 @@ describe("ponytail extension", () => {
269
290
  const ctx = createCommandContext();
270
291
 
271
292
  await events.get("session_start")({ reason: "startup" }, ctx);
272
- await commands.get("ponytail").handler("ultra", ctx);
293
+ await commands.get("ponytail").handler("lite", ctx);
273
294
  await events.get("input")({ source: "interactive" }, ctx);
274
295
 
275
296
  const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
276
- expect(result.systemPrompt).toMatch(/PONYTAIL ULTRA:/);
297
+ expect(result.systemPrompt).toMatch(/PONYTAIL MODE ACTIVE — level: lite\n/);
277
298
  }));
278
299
 
279
300
  it("setMode notifies when ctx has a UI", async () => withTempConfig(async () => {