@oh-my-pi/pi-coding-agent 1.340.0 → 1.341.0
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.
- package/CHANGELOG.md +42 -1
- package/package.json +1 -1
- package/src/cli/args.ts +8 -0
- package/src/core/agent-session.ts +32 -14
- package/src/core/model-resolver.ts +101 -0
- package/src/core/sdk.ts +50 -17
- package/src/core/session-manager.ts +117 -14
- package/src/core/settings-manager.ts +90 -19
- package/src/core/title-generator.ts +94 -0
- package/src/core/tools/bash.ts +1 -2
- package/src/core/tools/edit-diff.ts +2 -2
- package/src/core/tools/edit.ts +43 -5
- package/src/core/tools/grep.ts +3 -2
- package/src/core/tools/index.ts +73 -13
- package/src/core/tools/lsp/client.ts +45 -20
- package/src/core/tools/lsp/config.ts +708 -34
- package/src/core/tools/lsp/index.ts +423 -23
- package/src/core/tools/lsp/types.ts +5 -0
- package/src/core/tools/task/bundled-agents/explore.md +1 -1
- package/src/core/tools/task/bundled-agents/reviewer.md +1 -1
- package/src/core/tools/task/model-resolver.ts +52 -3
- package/src/core/tools/write.ts +67 -4
- package/src/index.ts +5 -0
- package/src/main.ts +23 -2
- package/src/modes/interactive/components/model-selector.ts +96 -18
- package/src/modes/interactive/components/session-selector.ts +20 -7
- package/src/modes/interactive/components/settings-defs.ts +50 -2
- package/src/modes/interactive/components/settings-selector.ts +8 -11
- package/src/modes/interactive/components/tool-execution.ts +18 -0
- package/src/modes/interactive/components/tree-selector.ts +2 -2
- package/src/modes/interactive/components/welcome.ts +40 -3
- package/src/modes/interactive/interactive-mode.ts +86 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.341.0] - 2026-01-03
|
|
6
|
+
### Added
|
|
7
|
+
|
|
8
|
+
- Added interruptMode setting to control when queued messages are processed during tool execution.
|
|
9
|
+
- Implemented getter and setter methods in SettingsManager for interrupt mode persistence.
|
|
10
|
+
- Exposed interruptMode configuration in interactive settings UI with immediate/wait options.
|
|
11
|
+
- Wired interrupt mode through AgentSession and SDK to enable runtime configuration.
|
|
12
|
+
- Model roles: Configure different models for different purposes (default, smol, slow) via `/model` selector
|
|
13
|
+
- Model selector key bindings: Enter sets default, S sets smol, L sets slow, Escape closes
|
|
14
|
+
- Model selector shows role markers: ✓ for default, ⚡ for smol, 🧠 for slow
|
|
15
|
+
- `pi/<role>` model aliases in Task tool agent definitions (e.g., `model: pi/smol, haiku, flash, mini`)
|
|
16
|
+
- Smol model auto-discovery using priority chain: haiku > flash > mini
|
|
17
|
+
- Slow model auto-discovery using priority chain: gpt-5.2-codex > codex > gpt > opus > pro
|
|
18
|
+
- CLI args for model roles: `--smol <model>` and `--slow <model>` (ephemeral, not persisted)
|
|
19
|
+
- Env var overrides: `PI_SMOL_MODEL` and `PI_SLOW_MODEL`
|
|
20
|
+
- Title generation now uses configured smol model from settings
|
|
21
|
+
- LSP diagnostics on edit: Edit tool can now return LSP diagnostics after editing code files. Disabled by default to avoid noise during multi-edit sequences. Enable via `lsp.diagnosticsOnEdit` setting.
|
|
22
|
+
- LSP workspace diagnostics: New `lsp action=workspace_diagnostics` command checks the entire project for errors. Auto-detects project type and uses appropriate checker (rust-analyzer/cargo for Rust, tsc for TypeScript, go build for Go, pyright for Python).
|
|
23
|
+
- LSP local binary resolution: LSP servers installed in project-local directories are now discovered automatically. Checks `node_modules/.bin/` for Node.js projects, `.venv/bin/`/`venv/bin/` for Python projects, and `vendor/bundle/bin/` for Ruby projects before falling back to `$PATH`.
|
|
24
|
+
- LSP format on write: Write tool now automatically formats code files using LSP after writing. Uses the language server's built-in formatter (e.g., rustfmt for Rust, gofmt for Go). Controlled via `lsp.formatOnWrite` setting (enabled by default).
|
|
25
|
+
- LSP diagnostics on write: Write tool now returns LSP diagnostics (errors/warnings) after writing code files. This gives immediate feedback on syntax errors and type issues. Controlled via `lsp.diagnosticsOnWrite` setting (enabled by default).
|
|
26
|
+
- LSP server warmup at startup: LSP servers are now started at launch to avoid cold-start delays when first writing files.
|
|
27
|
+
- LSP server status in welcome banner: Shows which language servers are active and ready.
|
|
28
|
+
- Edit fuzzy match setting: Added `edit.fuzzyMatch` setting (enabled by default) to control whether the edit tool accepts high-confidence fuzzy matches for whitespace/indentation differences. Toggle via `/settings`.
|
|
29
|
+
- Multi-server LSP diagnostics: Diagnostics now query all applicable language servers for a file type. For TypeScript/JavaScript projects with Biome, this means both type errors (from tsserver) and lint errors (from Biome) are reported together.
|
|
30
|
+
- Comprehensive LSP server configurations for 40+ languages including Rust, Go, Python, Java, Kotlin, Scala, Haskell, OCaml, Elixir, Ruby, PHP, C#, Lua, Nix, and many more. Each server includes sensible defaults for args, settings, and init options.
|
|
31
|
+
- Extended LSP config file search paths: Now searches for `lsp.json`, `.lsp.json` in project root and `.pi/` subdirectory, plus user-level configs in `~/.pi/` and home directory.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- LSP settings moved to dedicated "LSP" tab in `/settings` for better organization
|
|
36
|
+
- Improved grep tool description to document pagination options (`headLimit`, `offset`) and clarify recursive search behavior
|
|
37
|
+
- LSP idle timeout now disabled by default. Configure via `idleTimeoutMs` in lsp.json to auto-shutdown inactive servers.
|
|
38
|
+
- Model settings now use role-based storage (`modelRoles` map) instead of single `defaultProvider`/`defaultModel` fields. Supports multiple model roles (default, small, etc.)
|
|
39
|
+
- Session model persistence now uses `"provider/modelId"` string format with optional role field
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- Recent sessions now show in welcome banner (was never wired up).
|
|
44
|
+
- Auto-generated session titles: Sessions are now automatically titled based on the first message using a small model (Haiku/GPT-4o-mini/Flash). Titles are shown in the terminal window title, recent sessions list, and --resume picker. The resume picker shows title with dimmed first message preview below.
|
|
45
|
+
|
|
5
46
|
## [1.340.0] - 2026-01-03
|
|
6
47
|
|
|
7
48
|
### Changed
|
|
@@ -1286,4 +1327,4 @@ Initial public release.
|
|
|
1286
1327
|
- Git branch display in footer
|
|
1287
1328
|
- Message queueing during streaming responses
|
|
1288
1329
|
- OAuth integration for Gmail and Google Calendar access
|
|
1289
|
-
- HTML export with syntax highlighting and collapsible sections
|
|
1330
|
+
- HTML export with syntax highlighting and collapsible sections
|
package/package.json
CHANGED
package/src/cli/args.ts
CHANGED
|
@@ -12,6 +12,8 @@ export type Mode = "text" | "json" | "rpc";
|
|
|
12
12
|
export interface Args {
|
|
13
13
|
provider?: string;
|
|
14
14
|
model?: string;
|
|
15
|
+
smol?: string;
|
|
16
|
+
slow?: string;
|
|
15
17
|
apiKey?: string;
|
|
16
18
|
systemPrompt?: string;
|
|
17
19
|
appendSystemPrompt?: string;
|
|
@@ -69,6 +71,10 @@ export function parseArgs(args: string[]): Args {
|
|
|
69
71
|
result.provider = args[++i];
|
|
70
72
|
} else if (arg === "--model" && i + 1 < args.length) {
|
|
71
73
|
result.model = args[++i];
|
|
74
|
+
} else if (arg === "--smol" && i + 1 < args.length) {
|
|
75
|
+
result.smol = args[++i];
|
|
76
|
+
} else if (arg === "--slow" && i + 1 < args.length) {
|
|
77
|
+
result.slow = args[++i];
|
|
72
78
|
} else if (arg === "--api-key" && i + 1 < args.length) {
|
|
73
79
|
result.apiKey = args[++i];
|
|
74
80
|
} else if (arg === "--system-prompt" && i + 1 < args.length) {
|
|
@@ -148,6 +154,8 @@ ${chalk.bold("Usage:")}
|
|
|
148
154
|
${chalk.bold("Options:")}
|
|
149
155
|
--provider <name> Provider name (default: google)
|
|
150
156
|
--model <id> Model ID (default: gemini-2.5-flash)
|
|
157
|
+
--smol <id> Smol/fast model for lightweight tasks (or PI_SMOL_MODEL env)
|
|
158
|
+
--slow <id> Slow/reasoning model for thorough analysis (or PI_SLOW_MODEL env)
|
|
151
159
|
--api-key <key> API key (defaults to env vars)
|
|
152
160
|
--system-prompt <text> System prompt (default: coding assistant prompt)
|
|
153
161
|
--append-system-prompt <text> Append text or file contents to the system prompt
|
|
@@ -424,6 +424,11 @@ export class AgentSession {
|
|
|
424
424
|
return this.agent.getQueueMode();
|
|
425
425
|
}
|
|
426
426
|
|
|
427
|
+
/** Current interrupt mode */
|
|
428
|
+
get interruptMode(): "immediate" | "wait" {
|
|
429
|
+
return this.agent.getInterruptMode();
|
|
430
|
+
}
|
|
431
|
+
|
|
427
432
|
/** Current session file path, or undefined if sessions are disabled */
|
|
428
433
|
get sessionFile(): string | undefined {
|
|
429
434
|
return this.sessionManager.getSessionFile();
|
|
@@ -701,15 +706,15 @@ export class AgentSession {
|
|
|
701
706
|
* Validates API key, saves to session and settings.
|
|
702
707
|
* @throws Error if no API key available for the model
|
|
703
708
|
*/
|
|
704
|
-
async setModel(model: Model<any
|
|
709
|
+
async setModel(model: Model<any>, role: string = "default"): Promise<void> {
|
|
705
710
|
const apiKey = await this._modelRegistry.getApiKey(model);
|
|
706
711
|
if (!apiKey) {
|
|
707
712
|
throw new Error(`No API key for ${model.provider}/${model.id}`);
|
|
708
713
|
}
|
|
709
714
|
|
|
710
715
|
this.agent.setModel(model);
|
|
711
|
-
this.sessionManager.appendModelChange(model.provider
|
|
712
|
-
this.settingsManager.
|
|
716
|
+
this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, role);
|
|
717
|
+
this.settingsManager.setModelRole(role, `${model.provider}/${model.id}`);
|
|
713
718
|
|
|
714
719
|
// Re-clamp thinking level for new model's capabilities
|
|
715
720
|
this.setThinkingLevel(this.thinkingLevel);
|
|
@@ -747,8 +752,8 @@ export class AgentSession {
|
|
|
747
752
|
|
|
748
753
|
// Apply model
|
|
749
754
|
this.agent.setModel(next.model);
|
|
750
|
-
this.sessionManager.appendModelChange(next.model.provider
|
|
751
|
-
this.settingsManager.
|
|
755
|
+
this.sessionManager.appendModelChange(`${next.model.provider}/${next.model.id}`);
|
|
756
|
+
this.settingsManager.setModelRole("default", `${next.model.provider}/${next.model.id}`);
|
|
752
757
|
|
|
753
758
|
// Apply thinking level (setThinkingLevel clamps to model capabilities)
|
|
754
759
|
this.setThinkingLevel(next.thinkingLevel);
|
|
@@ -774,8 +779,8 @@ export class AgentSession {
|
|
|
774
779
|
}
|
|
775
780
|
|
|
776
781
|
this.agent.setModel(nextModel);
|
|
777
|
-
this.sessionManager.appendModelChange(nextModel.provider
|
|
778
|
-
this.settingsManager.
|
|
782
|
+
this.sessionManager.appendModelChange(`${nextModel.provider}/${nextModel.id}`);
|
|
783
|
+
this.settingsManager.setModelRole("default", `${nextModel.provider}/${nextModel.id}`);
|
|
779
784
|
|
|
780
785
|
// Re-clamp thinking level for new model's capabilities
|
|
781
786
|
this.setThinkingLevel(this.thinkingLevel);
|
|
@@ -861,6 +866,15 @@ export class AgentSession {
|
|
|
861
866
|
this.settingsManager.setQueueMode(mode);
|
|
862
867
|
}
|
|
863
868
|
|
|
869
|
+
/**
|
|
870
|
+
* Set interrupt mode.
|
|
871
|
+
* Saves to settings.
|
|
872
|
+
*/
|
|
873
|
+
setInterruptMode(mode: "immediate" | "wait"): void {
|
|
874
|
+
this.agent.setInterruptMode(mode);
|
|
875
|
+
this.settingsManager.setInterruptMode(mode);
|
|
876
|
+
}
|
|
877
|
+
|
|
864
878
|
// =========================================================================
|
|
865
879
|
// Compaction
|
|
866
880
|
// =========================================================================
|
|
@@ -1472,13 +1486,17 @@ export class AgentSession {
|
|
|
1472
1486
|
this.agent.replaceMessages(sessionContext.messages);
|
|
1473
1487
|
|
|
1474
1488
|
// Restore model if saved
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
const
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
this.
|
|
1489
|
+
const defaultModelStr = sessionContext.models.default;
|
|
1490
|
+
if (defaultModelStr) {
|
|
1491
|
+
const slashIdx = defaultModelStr.indexOf("/");
|
|
1492
|
+
if (slashIdx > 0) {
|
|
1493
|
+
const provider = defaultModelStr.slice(0, slashIdx);
|
|
1494
|
+
const modelId = defaultModelStr.slice(slashIdx + 1);
|
|
1495
|
+
const availableModels = await this._modelRegistry.getAvailable();
|
|
1496
|
+
const match = availableModels.find((m) => m.provider === provider && m.id === modelId);
|
|
1497
|
+
if (match) {
|
|
1498
|
+
this.agent.setModel(match);
|
|
1499
|
+
}
|
|
1482
1500
|
}
|
|
1483
1501
|
}
|
|
1484
1502
|
|
|
@@ -30,6 +30,29 @@ export interface ScopedModel {
|
|
|
30
30
|
thinkingLevel: ThinkingLevel;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** Priority chain for auto-discovering smol/fast models */
|
|
34
|
+
export const SMOL_MODEL_PRIORITY = ["claude-haiku-4-5", "haiku", "flash", "mini"];
|
|
35
|
+
|
|
36
|
+
/** Priority chain for auto-discovering slow/comprehensive models (reasoning, codex) */
|
|
37
|
+
export const SLOW_MODEL_PRIORITY = ["gpt-5.2-codex", "gpt-5.2", "codex", "gpt", "opus", "pro"];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse a model string in "provider/modelId" format.
|
|
41
|
+
* Returns undefined if the format is invalid.
|
|
42
|
+
*/
|
|
43
|
+
export function parseModelString(modelStr: string): { provider: string; id: string } | undefined {
|
|
44
|
+
const slashIdx = modelStr.indexOf("/");
|
|
45
|
+
if (slashIdx <= 0) return undefined;
|
|
46
|
+
return { provider: modelStr.slice(0, slashIdx), id: modelStr.slice(slashIdx + 1) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Format a model as "provider/modelId" string.
|
|
51
|
+
*/
|
|
52
|
+
export function formatModelString(model: Model<Api>): string {
|
|
53
|
+
return `${model.provider}/${model.id}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
33
56
|
/**
|
|
34
57
|
* Helper to check if a model ID looks like an alias (no date suffix)
|
|
35
58
|
* Dates are typically in format: -20241022 or -20250929
|
|
@@ -391,3 +414,81 @@ export async function restoreModelFromSession(
|
|
|
391
414
|
// No models available
|
|
392
415
|
return { model: undefined, fallbackMessage: undefined };
|
|
393
416
|
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Find a smol/fast model using the priority chain.
|
|
420
|
+
* Tries exact matches first, then fuzzy matches.
|
|
421
|
+
*
|
|
422
|
+
* @param modelRegistry The model registry to search
|
|
423
|
+
* @param savedModel Optional saved model string from settings (provider/modelId)
|
|
424
|
+
* @returns The best available smol model, or undefined if none found
|
|
425
|
+
*/
|
|
426
|
+
export async function findSmolModel(
|
|
427
|
+
modelRegistry: ModelRegistry,
|
|
428
|
+
savedModel?: string,
|
|
429
|
+
): Promise<Model<Api> | undefined> {
|
|
430
|
+
const availableModels = await modelRegistry.getAvailable();
|
|
431
|
+
if (availableModels.length === 0) return undefined;
|
|
432
|
+
|
|
433
|
+
// 1. Try saved model from settings
|
|
434
|
+
if (savedModel) {
|
|
435
|
+
const parsed = parseModelString(savedModel);
|
|
436
|
+
if (parsed) {
|
|
437
|
+
const match = availableModels.find((m) => m.provider === parsed.provider && m.id === parsed.id);
|
|
438
|
+
if (match) return match;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// 2. Try priority chain
|
|
443
|
+
for (const pattern of SMOL_MODEL_PRIORITY) {
|
|
444
|
+
// Try exact match first
|
|
445
|
+
const exactMatch = availableModels.find((m) => m.id.toLowerCase() === pattern.toLowerCase());
|
|
446
|
+
if (exactMatch) return exactMatch;
|
|
447
|
+
|
|
448
|
+
// Try fuzzy match (substring)
|
|
449
|
+
const fuzzyMatch = availableModels.find((m) => m.id.toLowerCase().includes(pattern.toLowerCase()));
|
|
450
|
+
if (fuzzyMatch) return fuzzyMatch;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// 3. Fallback to first available (same as default)
|
|
454
|
+
return availableModels[0];
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Find a slow/comprehensive model using the priority chain.
|
|
459
|
+
* Prioritizes reasoning and codex models for thorough analysis.
|
|
460
|
+
*
|
|
461
|
+
* @param modelRegistry The model registry to search
|
|
462
|
+
* @param savedModel Optional saved model string from settings (provider/modelId)
|
|
463
|
+
* @returns The best available slow model, or undefined if none found
|
|
464
|
+
*/
|
|
465
|
+
export async function findSlowModel(
|
|
466
|
+
modelRegistry: ModelRegistry,
|
|
467
|
+
savedModel?: string,
|
|
468
|
+
): Promise<Model<Api> | undefined> {
|
|
469
|
+
const availableModels = await modelRegistry.getAvailable();
|
|
470
|
+
if (availableModels.length === 0) return undefined;
|
|
471
|
+
|
|
472
|
+
// 1. Try saved model from settings
|
|
473
|
+
if (savedModel) {
|
|
474
|
+
const parsed = parseModelString(savedModel);
|
|
475
|
+
if (parsed) {
|
|
476
|
+
const match = availableModels.find((m) => m.provider === parsed.provider && m.id === parsed.id);
|
|
477
|
+
if (match) return match;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// 2. Try priority chain
|
|
482
|
+
for (const pattern of SLOW_MODEL_PRIORITY) {
|
|
483
|
+
// Try exact match first
|
|
484
|
+
const exactMatch = availableModels.find((m) => m.id.toLowerCase() === pattern.toLowerCase());
|
|
485
|
+
if (exactMatch) return exactMatch;
|
|
486
|
+
|
|
487
|
+
// Try fuzzy match (substring)
|
|
488
|
+
const fuzzyMatch = availableModels.find((m) => m.id.toLowerCase().includes(pattern.toLowerCase()));
|
|
489
|
+
if (fuzzyMatch) return fuzzyMatch;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// 3. Fallback to first available (same as default)
|
|
493
|
+
return availableModels[0];
|
|
494
|
+
}
|
package/src/core/sdk.ts
CHANGED
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
readOnlyTools,
|
|
79
79
|
readTool,
|
|
80
80
|
type Tool,
|
|
81
|
+
warmupLspServers,
|
|
81
82
|
writeTool,
|
|
82
83
|
} from "./tools/index.js";
|
|
83
84
|
|
|
@@ -146,6 +147,8 @@ export interface CreateAgentSessionResult {
|
|
|
146
147
|
mcpManager?: MCPManager;
|
|
147
148
|
/** Warning if session was restored with a different model than saved */
|
|
148
149
|
modelFallbackMessage?: string;
|
|
150
|
+
/** LSP servers that were warmed up at startup */
|
|
151
|
+
lspServers?: Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }>;
|
|
149
152
|
}
|
|
150
153
|
|
|
151
154
|
// Re-exports
|
|
@@ -323,8 +326,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
|
|
323
326
|
export function loadSettings(cwd?: string, agentDir?: string): Settings {
|
|
324
327
|
const manager = SettingsManager.create(cwd ?? process.cwd(), agentDir ?? getDefaultAgentDir());
|
|
325
328
|
return {
|
|
326
|
-
|
|
327
|
-
defaultModel: manager.getDefaultModel(),
|
|
329
|
+
modelRoles: manager.getModelRoles(),
|
|
328
330
|
defaultThinkingLevel: manager.getDefaultThinkingLevel(),
|
|
329
331
|
queueMode: manager.getQueueMode(),
|
|
330
332
|
theme: manager.getTheme(),
|
|
@@ -479,24 +481,34 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
479
481
|
let modelFallbackMessage: string | undefined;
|
|
480
482
|
|
|
481
483
|
// If session has data, try to restore model from it
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
484
|
+
const defaultModelStr = existingSession.models.default;
|
|
485
|
+
if (!model && hasExistingSession && defaultModelStr) {
|
|
486
|
+
const slashIdx = defaultModelStr.indexOf("/");
|
|
487
|
+
if (slashIdx > 0) {
|
|
488
|
+
const provider = defaultModelStr.slice(0, slashIdx);
|
|
489
|
+
const modelId = defaultModelStr.slice(slashIdx + 1);
|
|
490
|
+
const restoredModel = modelRegistry.find(provider, modelId);
|
|
491
|
+
if (restoredModel && (await modelRegistry.getApiKey(restoredModel))) {
|
|
492
|
+
model = restoredModel;
|
|
493
|
+
}
|
|
494
|
+
if (!model) {
|
|
495
|
+
modelFallbackMessage = `Could not restore model ${defaultModelStr}`;
|
|
496
|
+
}
|
|
489
497
|
}
|
|
490
498
|
}
|
|
491
499
|
|
|
492
500
|
// If still no model, try settings default
|
|
493
501
|
if (!model) {
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
502
|
+
const settingsDefaultModel = settingsManager.getModelRole("default");
|
|
503
|
+
if (settingsDefaultModel) {
|
|
504
|
+
const slashIdx = settingsDefaultModel.indexOf("/");
|
|
505
|
+
if (slashIdx > 0) {
|
|
506
|
+
const provider = settingsDefaultModel.slice(0, slashIdx);
|
|
507
|
+
const modelId = settingsDefaultModel.slice(slashIdx + 1);
|
|
508
|
+
const settingsModel = modelRegistry.find(provider, modelId);
|
|
509
|
+
if (settingsModel && (await modelRegistry.getApiKey(settingsModel))) {
|
|
510
|
+
model = settingsModel;
|
|
511
|
+
}
|
|
500
512
|
}
|
|
501
513
|
}
|
|
502
514
|
}
|
|
@@ -566,7 +578,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
566
578
|
const sessionContext = {
|
|
567
579
|
getSessionFile: () => sessionManager.getSessionFile() ?? null,
|
|
568
580
|
};
|
|
569
|
-
const builtInTools =
|
|
581
|
+
const builtInTools =
|
|
582
|
+
options.tools ??
|
|
583
|
+
createCodingTools(cwd, options.hasUI ?? false, sessionContext, {
|
|
584
|
+
lspFormatOnWrite: settingsManager.getLspFormatOnWrite(),
|
|
585
|
+
lspDiagnosticsOnWrite: settingsManager.getLspDiagnosticsOnWrite(),
|
|
586
|
+
lspDiagnosticsOnEdit: settingsManager.getLspDiagnosticsOnEdit(),
|
|
587
|
+
editFuzzyMatch: settingsManager.getEditFuzzyMatch(),
|
|
588
|
+
});
|
|
570
589
|
time("createCodingTools");
|
|
571
590
|
|
|
572
591
|
let customToolsResult: CustomToolsLoadResult;
|
|
@@ -728,6 +747,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
728
747
|
}
|
|
729
748
|
: undefined,
|
|
730
749
|
queueMode: settingsManager.getQueueMode(),
|
|
750
|
+
interruptMode: settingsManager.getInterruptMode(),
|
|
731
751
|
getToolContext: toolContextStore.getContext,
|
|
732
752
|
getApiKey: async () => {
|
|
733
753
|
const currentModel = agent.state.model;
|
|
@@ -749,7 +769,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
749
769
|
} else {
|
|
750
770
|
// Save initial model and thinking level for new sessions so they can be restored on resume
|
|
751
771
|
if (model) {
|
|
752
|
-
sessionManager.appendModelChange(model.provider
|
|
772
|
+
sessionManager.appendModelChange(`${model.provider}/${model.id}`);
|
|
753
773
|
}
|
|
754
774
|
sessionManager.appendThinkingLevelChange(thinkingLevel);
|
|
755
775
|
}
|
|
@@ -767,10 +787,23 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
767
787
|
});
|
|
768
788
|
time("createAgentSession");
|
|
769
789
|
|
|
790
|
+
// Warm up LSP servers (connects to detected servers)
|
|
791
|
+
let lspServers: CreateAgentSessionResult["lspServers"];
|
|
792
|
+
if (settingsManager.getLspDiagnosticsOnWrite()) {
|
|
793
|
+
try {
|
|
794
|
+
const result = await warmupLspServers(cwd);
|
|
795
|
+
lspServers = result.servers;
|
|
796
|
+
time("warmupLspServers");
|
|
797
|
+
} catch {
|
|
798
|
+
// Ignore warmup errors
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
770
802
|
return {
|
|
771
803
|
session,
|
|
772
804
|
customToolsResult,
|
|
773
805
|
mcpManager,
|
|
774
806
|
modelFallbackMessage,
|
|
807
|
+
lspServers,
|
|
775
808
|
};
|
|
776
809
|
}
|
|
@@ -28,6 +28,7 @@ export interface SessionHeader {
|
|
|
28
28
|
type: "session";
|
|
29
29
|
version?: number; // v1 sessions don't have this
|
|
30
30
|
id: string;
|
|
31
|
+
title?: string; // Auto-generated title from first message
|
|
31
32
|
timestamp: string;
|
|
32
33
|
cwd: string;
|
|
33
34
|
parentSession?: string;
|
|
@@ -56,8 +57,10 @@ export interface ThinkingLevelChangeEntry extends SessionEntryBase {
|
|
|
56
57
|
|
|
57
58
|
export interface ModelChangeEntry extends SessionEntryBase {
|
|
58
59
|
type: "model_change";
|
|
59
|
-
provider
|
|
60
|
-
|
|
60
|
+
/** Model in "provider/modelId" format */
|
|
61
|
+
model: string;
|
|
62
|
+
/** Role: "default", "smol", "slow", etc. Undefined treated as "default" */
|
|
63
|
+
role?: string;
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
export interface CompactionEntry<T = unknown> extends SessionEntryBase {
|
|
@@ -149,12 +152,14 @@ export interface SessionTreeNode {
|
|
|
149
152
|
export interface SessionContext {
|
|
150
153
|
messages: AgentMessage[];
|
|
151
154
|
thinkingLevel: string;
|
|
152
|
-
|
|
155
|
+
/** Model roles: { default: "provider/modelId", small: "provider/modelId", ... } */
|
|
156
|
+
models: Record<string, string>;
|
|
153
157
|
}
|
|
154
158
|
|
|
155
159
|
export interface SessionInfo {
|
|
156
160
|
path: string;
|
|
157
161
|
id: string;
|
|
162
|
+
title?: string;
|
|
158
163
|
created: Date;
|
|
159
164
|
modified: Date;
|
|
160
165
|
messageCount: number;
|
|
@@ -290,7 +295,7 @@ export function buildSessionContext(
|
|
|
290
295
|
let leaf: SessionEntry | undefined;
|
|
291
296
|
if (leafId === null) {
|
|
292
297
|
// Explicitly null - return no messages (navigated to before first entry)
|
|
293
|
-
return { messages: [], thinkingLevel: "off",
|
|
298
|
+
return { messages: [], thinkingLevel: "off", models: {} };
|
|
294
299
|
}
|
|
295
300
|
if (leafId) {
|
|
296
301
|
leaf = byId.get(leafId);
|
|
@@ -301,7 +306,7 @@ export function buildSessionContext(
|
|
|
301
306
|
}
|
|
302
307
|
|
|
303
308
|
if (!leaf) {
|
|
304
|
-
return { messages: [], thinkingLevel: "off",
|
|
309
|
+
return { messages: [], thinkingLevel: "off", models: {} };
|
|
305
310
|
}
|
|
306
311
|
|
|
307
312
|
// Walk from leaf to root, collecting path
|
|
@@ -314,16 +319,21 @@ export function buildSessionContext(
|
|
|
314
319
|
|
|
315
320
|
// Extract settings and find compaction
|
|
316
321
|
let thinkingLevel = "off";
|
|
317
|
-
|
|
322
|
+
const models: Record<string, string> = {};
|
|
318
323
|
let compaction: CompactionEntry | null = null;
|
|
319
324
|
|
|
320
325
|
for (const entry of path) {
|
|
321
326
|
if (entry.type === "thinking_level_change") {
|
|
322
327
|
thinkingLevel = entry.thinkingLevel;
|
|
323
328
|
} else if (entry.type === "model_change") {
|
|
324
|
-
|
|
329
|
+
// New format: { model: "provider/id", role?: string }
|
|
330
|
+
if (entry.model) {
|
|
331
|
+
const role = entry.role ?? "default";
|
|
332
|
+
models[role] = entry.model;
|
|
333
|
+
}
|
|
325
334
|
} else if (entry.type === "message" && entry.message.role === "assistant") {
|
|
326
|
-
|
|
335
|
+
// Infer default model from assistant messages
|
|
336
|
+
models.default = `${entry.message.provider}/${entry.message.model}`;
|
|
327
337
|
} else if (entry.type === "compaction") {
|
|
328
338
|
compaction = entry;
|
|
329
339
|
}
|
|
@@ -379,7 +389,7 @@ export function buildSessionContext(
|
|
|
379
389
|
}
|
|
380
390
|
}
|
|
381
391
|
|
|
382
|
-
return { messages, thinkingLevel,
|
|
392
|
+
return { messages, thinkingLevel, models };
|
|
383
393
|
}
|
|
384
394
|
|
|
385
395
|
/**
|
|
@@ -454,6 +464,67 @@ export function findMostRecentSession(sessionDir: string): string | null {
|
|
|
454
464
|
}
|
|
455
465
|
}
|
|
456
466
|
|
|
467
|
+
/** Recent session info for display */
|
|
468
|
+
export interface RecentSessionInfo {
|
|
469
|
+
name: string;
|
|
470
|
+
path: string;
|
|
471
|
+
timeAgo: string;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Format a time difference as a human-readable string */
|
|
475
|
+
function formatTimeAgo(date: Date): string {
|
|
476
|
+
const now = Date.now();
|
|
477
|
+
const diffMs = now - date.getTime();
|
|
478
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
479
|
+
const diffHours = Math.floor(diffMs / 3600000);
|
|
480
|
+
const diffDays = Math.floor(diffMs / 86400000);
|
|
481
|
+
|
|
482
|
+
if (diffMins < 1) return "just now";
|
|
483
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
484
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
485
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
486
|
+
return date.toLocaleDateString();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Get recent sessions for display in welcome screen */
|
|
490
|
+
export function getRecentSessions(sessionDir: string, limit = 3): RecentSessionInfo[] {
|
|
491
|
+
try {
|
|
492
|
+
const files = readdirSync(sessionDir)
|
|
493
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
494
|
+
.map((f) => join(sessionDir, f))
|
|
495
|
+
.filter(isValidSessionFile)
|
|
496
|
+
.map((path) => {
|
|
497
|
+
const stat = statSync(path);
|
|
498
|
+
// Try to get session title or id from first line
|
|
499
|
+
let name = path.split("/").pop()?.replace(".jsonl", "") ?? "Unknown";
|
|
500
|
+
try {
|
|
501
|
+
const content = readFileSync(path, "utf-8");
|
|
502
|
+
const firstLine = content.split("\n")[0];
|
|
503
|
+
if (firstLine) {
|
|
504
|
+
const header = JSON.parse(firstLine) as SessionHeader;
|
|
505
|
+
if (header.type === "session") {
|
|
506
|
+
// Prefer title over id
|
|
507
|
+
name = header.title ?? header.id ?? name;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
} catch {
|
|
511
|
+
// Use filename as fallback
|
|
512
|
+
}
|
|
513
|
+
return { path, name, mtime: stat.mtime };
|
|
514
|
+
})
|
|
515
|
+
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
|
|
516
|
+
.slice(0, limit);
|
|
517
|
+
|
|
518
|
+
return files.map((f) => ({
|
|
519
|
+
name: f.name.length > 40 ? `${f.name.slice(0, 37)}...` : f.name,
|
|
520
|
+
path: f.path,
|
|
521
|
+
timeAgo: formatTimeAgo(f.mtime),
|
|
522
|
+
}));
|
|
523
|
+
} catch {
|
|
524
|
+
return [];
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
457
528
|
/**
|
|
458
529
|
* Manages conversation sessions as append-only trees stored in JSONL files.
|
|
459
530
|
*
|
|
@@ -467,6 +538,7 @@ export function findMostRecentSession(sessionDir: string): string | null {
|
|
|
467
538
|
*/
|
|
468
539
|
export class SessionManager {
|
|
469
540
|
private sessionId: string = "";
|
|
541
|
+
private sessionTitle: string | undefined;
|
|
470
542
|
private sessionFile: string | undefined;
|
|
471
543
|
private sessionDir: string;
|
|
472
544
|
private cwd: string;
|
|
@@ -499,6 +571,7 @@ export class SessionManager {
|
|
|
499
571
|
this.fileEntries = loadEntriesFromFile(this.sessionFile);
|
|
500
572
|
const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;
|
|
501
573
|
this.sessionId = header?.id ?? crypto.randomUUID();
|
|
574
|
+
this.sessionTitle = header?.title;
|
|
502
575
|
|
|
503
576
|
if (migrateToCurrentVersion(this.fileEntries)) {
|
|
504
577
|
this._rewriteFile();
|
|
@@ -579,6 +652,31 @@ export class SessionManager {
|
|
|
579
652
|
return this.sessionFile;
|
|
580
653
|
}
|
|
581
654
|
|
|
655
|
+
getSessionTitle(): string | undefined {
|
|
656
|
+
return this.sessionTitle;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
setSessionTitle(title: string): void {
|
|
660
|
+
this.sessionTitle = title;
|
|
661
|
+
// Update the session file header with the title
|
|
662
|
+
if (this.persist && this.sessionFile && existsSync(this.sessionFile)) {
|
|
663
|
+
try {
|
|
664
|
+
const content = readFileSync(this.sessionFile, "utf-8");
|
|
665
|
+
const lines = content.split("\n");
|
|
666
|
+
if (lines.length > 0) {
|
|
667
|
+
const header = JSON.parse(lines[0]) as SessionHeader;
|
|
668
|
+
if (header.type === "session") {
|
|
669
|
+
header.title = title;
|
|
670
|
+
lines[0] = JSON.stringify(header);
|
|
671
|
+
writeFileSync(this.sessionFile, lines.join("\n"));
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
} catch {
|
|
675
|
+
// Ignore errors updating title
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
582
680
|
_persist(entry: SessionEntry): void {
|
|
583
681
|
if (!this.persist || !this.sessionFile) return;
|
|
584
682
|
|
|
@@ -633,15 +731,19 @@ export class SessionManager {
|
|
|
633
731
|
return entry.id;
|
|
634
732
|
}
|
|
635
733
|
|
|
636
|
-
/**
|
|
637
|
-
|
|
734
|
+
/**
|
|
735
|
+
* Append a model change as child of current leaf, then advance leaf. Returns entry id.
|
|
736
|
+
* @param model Model in "provider/modelId" format
|
|
737
|
+
* @param role Optional role (default: "default")
|
|
738
|
+
*/
|
|
739
|
+
appendModelChange(model: string, role?: string): string {
|
|
638
740
|
const entry: ModelChangeEntry = {
|
|
639
741
|
type: "model_change",
|
|
640
742
|
id: generateId(this.byId),
|
|
641
743
|
parentId: this.leafId,
|
|
642
744
|
timestamp: new Date().toISOString(),
|
|
643
|
-
|
|
644
|
-
|
|
745
|
+
model,
|
|
746
|
+
role,
|
|
645
747
|
};
|
|
646
748
|
this._appendEntry(entry);
|
|
647
749
|
return entry.id;
|
|
@@ -1061,7 +1163,7 @@ export class SessionManager {
|
|
|
1061
1163
|
if (lines.length === 0) continue;
|
|
1062
1164
|
|
|
1063
1165
|
// Check first line for valid session header
|
|
1064
|
-
let header: { type: string; id: string; timestamp: string } | null = null;
|
|
1166
|
+
let header: { type: string; id: string; title?: string; timestamp: string } | null = null;
|
|
1065
1167
|
try {
|
|
1066
1168
|
const first = JSON.parse(lines[0]);
|
|
1067
1169
|
if (first.type === "session" && first.id) {
|
|
@@ -1107,6 +1209,7 @@ export class SessionManager {
|
|
|
1107
1209
|
sessions.push({
|
|
1108
1210
|
path: file,
|
|
1109
1211
|
id: header.id,
|
|
1212
|
+
title: header.title,
|
|
1110
1213
|
created: new Date(header.timestamp),
|
|
1111
1214
|
modified: stats.mtime,
|
|
1112
1215
|
messageCount,
|