jeo-code 0.1.0 → 0.4.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.
- package/README.ja.md +160 -0
- package/README.ko.md +160 -0
- package/README.md +115 -297
- package/README.zh.md +160 -0
- package/package.json +11 -6
- package/scripts/install.sh +28 -28
- package/scripts/uninstall.sh +17 -15
- package/src/AGENTS.md +50 -0
- package/src/agent/AGENTS.md +49 -0
- package/src/agent/bash-fixups.ts +103 -0
- package/src/agent/compaction.ts +410 -19
- package/src/agent/config-schema.ts +119 -5
- package/src/agent/context-files.ts +314 -17
- package/src/agent/dev/AGENTS.md +36 -0
- package/src/agent/dev/advanced-analyzer.ts +12 -0
- package/src/agent/dev/evolution-bridge.ts +82 -0
- package/src/agent/dev/evolution-logger.ts +41 -0
- package/src/agent/dev/self-analysis.ts +64 -0
- package/src/agent/dev/self-improve.ts +24 -0
- package/src/agent/dev/spec-automation.ts +49 -0
- package/src/agent/engine.ts +808 -54
- package/src/agent/hooks.ts +273 -0
- package/src/agent/loop.ts +21 -1
- package/src/agent/memory.ts +201 -0
- package/src/agent/model-recency.ts +32 -0
- package/src/agent/output-minimizer.ts +108 -0
- package/src/agent/output-util.ts +64 -0
- package/src/agent/plan.ts +187 -0
- package/src/agent/seed.ts +52 -0
- package/src/agent/session.ts +235 -21
- package/src/agent/state.ts +286 -39
- package/src/agent/step-budget.ts +232 -0
- package/src/agent/subagents.ts +223 -26
- package/src/agent/task-tool.ts +272 -0
- package/src/agent/todo-tool.ts +87 -0
- package/src/agent/tokenizer.ts +117 -0
- package/src/agent/tool-registry.ts +54 -0
- package/src/agent/tools.ts +624 -103
- package/src/agent/web-search.ts +538 -0
- package/src/ai/AGENTS.md +44 -0
- package/src/ai/index.ts +1 -0
- package/src/ai/model-catalog-compat.ts +3 -1
- package/src/ai/model-catalog.ts +74 -9
- package/src/ai/model-discovery.ts +215 -17
- package/src/ai/model-manager.ts +346 -32
- package/src/ai/model-picker.ts +1 -1
- package/src/ai/model-registry.ts +4 -2
- package/src/ai/pricing.ts +84 -0
- package/src/ai/provider-registry.ts +23 -0
- package/src/ai/provider-status.ts +60 -16
- package/src/ai/providers/AGENTS.md +42 -0
- package/src/ai/providers/anthropic.ts +250 -31
- package/src/ai/providers/antigravity.ts +219 -0
- package/src/ai/providers/errors.ts +15 -1
- package/src/ai/providers/gemini.ts +196 -13
- package/src/ai/providers/ollama.ts +37 -7
- package/src/ai/providers/openai-responses.ts +173 -0
- package/src/ai/providers/openai.ts +64 -12
- package/src/ai/sse.ts +4 -1
- package/src/ai/types.ts +18 -1
- package/src/auth/AGENTS.md +41 -0
- package/src/auth/callback-server.ts +6 -1
- package/src/auth/flows/AGENTS.md +32 -0
- package/src/auth/flows/antigravity.ts +151 -0
- package/src/auth/flows/google-project.ts +190 -0
- package/src/auth/flows/google.ts +39 -18
- package/src/auth/flows/index.ts +15 -5
- package/src/auth/flows/openai.ts +2 -2
- package/src/auth/oauth.ts +8 -0
- package/src/auth/refresh.ts +44 -27
- package/src/auth/storage.ts +149 -26
- package/src/auth/types.ts +1 -1
- package/src/autopilot.ts +362 -0
- package/src/bun-imports.d.ts +4 -0
- package/src/cli/AGENTS.md +39 -0
- package/src/cli/runner.ts +148 -14
- package/src/cli.ts +13 -4
- package/src/commands/AGENTS.md +40 -0
- package/src/commands/approve.ts +62 -3
- package/src/commands/auth.ts +167 -25
- package/src/commands/chat.ts +37 -8
- package/src/commands/deep-interview.ts +633 -175
- package/src/commands/doctor.ts +84 -37
- package/src/commands/evolve-core.ts +18 -0
- package/src/commands/evolve.ts +2 -1
- package/src/commands/export.ts +176 -0
- package/src/commands/gjc.ts +52 -0
- package/src/commands/launch.ts +3549 -240
- package/src/commands/mcp.ts +3 -3
- package/src/commands/ooo-seed.ts +19 -0
- package/src/commands/ralplan.ts +253 -35
- package/src/commands/resume.ts +1 -1
- package/src/commands/session.ts +183 -0
- package/src/commands/setup-helpers.ts +10 -3
- package/src/commands/setup.ts +57 -16
- package/src/commands/skills.ts +78 -18
- package/src/commands/state.ts +198 -0
- package/src/commands/status.ts +84 -0
- package/src/commands/team.ts +340 -212
- package/src/commands/ultragoal.ts +122 -61
- package/src/commands/update.ts +244 -0
- package/src/ledger.ts +270 -0
- package/src/mcp/AGENTS.md +38 -0
- package/src/mcp/server.ts +115 -14
- package/src/mcp/tools.ts +42 -22
- package/src/md-modules.d.ts +4 -0
- package/src/prompts/AGENTS.md +41 -0
- package/src/prompts/agents/AGENTS.md +35 -0
- package/src/prompts/agents/architect.md +35 -0
- package/src/prompts/agents/critic.md +37 -0
- package/src/prompts/agents/executor.md +36 -0
- package/src/prompts/agents/planner.md +37 -0
- package/src/prompts/skills/AGENTS.md +36 -0
- package/src/prompts/skills/deep-dive/AGENTS.md +31 -0
- package/src/prompts/skills/deep-dive/SKILL.md +13 -0
- package/src/prompts/skills/deep-interview/AGENTS.md +31 -0
- package/src/prompts/skills/deep-interview/SKILL.md +12 -0
- package/src/prompts/skills/gjc/AGENTS.md +31 -0
- package/src/prompts/skills/gjc/SKILL.md +15 -0
- package/src/prompts/skills/ralplan/AGENTS.md +31 -0
- package/src/prompts/skills/ralplan/SKILL.md +11 -0
- package/src/prompts/skills/team/AGENTS.md +31 -0
- package/src/prompts/skills/team/SKILL.md +11 -0
- package/src/prompts/skills/ultragoal/AGENTS.md +31 -0
- package/src/prompts/skills/ultragoal/SKILL.md +11 -0
- package/src/skills/AGENTS.md +38 -0
- package/src/skills/catalog.ts +565 -31
- package/src/tui/AGENTS.md +43 -0
- package/src/tui/app.ts +1181 -92
- package/src/tui/components/AGENTS.md +42 -0
- package/src/tui/components/ascii-art.ts +257 -15
- package/src/tui/components/autocomplete.ts +98 -16
- package/src/tui/components/autopilot-status.ts +65 -0
- package/src/tui/components/category-index.ts +49 -0
- package/src/tui/components/code-view.ts +54 -11
- package/src/tui/components/color.ts +171 -2
- package/src/tui/components/config-panel.ts +82 -15
- package/src/tui/components/duration.ts +38 -0
- package/src/tui/components/evolution.ts +3 -3
- package/src/tui/components/footer.ts +91 -42
- package/src/tui/components/forge.ts +426 -31
- package/src/tui/components/hints.ts +54 -0
- package/src/tui/components/hud.ts +73 -0
- package/src/tui/components/index.ts +4 -0
- package/src/tui/components/input-box.ts +150 -0
- package/src/tui/components/layout.ts +11 -3
- package/src/tui/components/live-model-picker.ts +108 -0
- package/src/tui/components/markdown-table.ts +140 -0
- package/src/tui/components/markdown-text.ts +97 -0
- package/src/tui/components/meter.ts +4 -1
- package/src/tui/components/model-picker.ts +3 -2
- package/src/tui/components/provider-picker.ts +3 -2
- package/src/tui/components/section.ts +70 -0
- package/src/tui/components/select-list.ts +40 -10
- package/src/tui/components/skill-picker.ts +25 -0
- package/src/tui/components/slash.ts +244 -21
- package/src/tui/components/status.ts +272 -11
- package/src/tui/components/step-timeline.ts +218 -0
- package/src/tui/components/stream.ts +26 -9
- package/src/tui/components/themes.ts +212 -6
- package/src/tui/components/todo-card.ts +47 -0
- package/src/tui/components/tool-list.ts +58 -12
- package/src/tui/components/transcript.ts +120 -0
- package/src/tui/components/update-box.ts +31 -0
- package/src/tui/components/welcome.ts +162 -0
- package/src/tui/components/width.ts +163 -0
- package/src/tui/monitoring/AGENTS.md +31 -0
- package/src/tui/monitoring/hud-view.ts +55 -0
- package/src/tui/renderer.ts +112 -3
- package/src/tui/terminal.ts +40 -33
- package/src/util/AGENTS.md +39 -0
- package/src/util/clipboard-image.ts +118 -0
- package/src/util/env.ts +12 -0
- package/src/util/provider-error.ts +78 -0
- package/src/util/retry.ts +91 -6
- package/src/util/update-check.ts +64 -0
- package/src/commands/models.ts +0 -104
package/src/commands/launch.ts
CHANGED
|
@@ -1,67 +1,757 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
|
-
import { runAgentLoop, executorSystemPrompt } from "../agent/engine";
|
|
2
|
+
import { runAgentLoop, executorSystemPrompt, DEFAULT_TOOLS, TOOL_PROTOCOL, WORKING_DISCIPLINE, type AgentLoopEvents } from "../agent/engine";
|
|
3
|
+
import { initialDynamicStepLimit } from "../agent/step-budget";
|
|
4
|
+
import { memoryPromptSection, spawnDetachedDistill } from "../agent/memory";
|
|
5
|
+
import { createTaskTool, taskToolProtocolLine, type TaskSubEvent } from "../agent/task-tool";
|
|
6
|
+
import { createTodoTool, TODO_TOOL_PROTOCOL_LINE } from "../agent/todo-tool";
|
|
3
7
|
import { LaunchTui } from "../tui/app";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
+
import { runDeepInterviewEngine } from "./deep-interview";
|
|
9
|
+
import { runRalplanEngine } from "./ralplan";
|
|
10
|
+
import { runTeamEngine } from "./team";
|
|
11
|
+
import { runUltragoalEngine } from "./ultragoal";
|
|
12
|
+
import { skillsPromptSection, loadSkills, formatSkill, buildSkillTask, getSkillFrom, skillSlashAliases, workflowSkillsForPrompt, parseSkillInvocation, looksLikeSkillEcho, skillInvocationCard, type SkillDoc } from "../skills/catalog";
|
|
13
|
+
import { formatForgeBox } from "../tui/components/forge";
|
|
14
|
+
import { interactiveOAuthLogin } from "./auth";
|
|
15
|
+
import { logoutOAuth } from "../auth";
|
|
16
|
+
import type { AuthProvider } from "../auth";
|
|
17
|
+
import { matchSlash, isSlashAttempt, formatSlashCommandList, formatSlashPreview, slashPreviewMatches, activeTriggerToken, tabCompleteSelection, type SlashCommandInfo } from "../tui/components/slash";
|
|
18
|
+
import { staticCompletionContext, readlineCompleter, formatCompletionPreview, tokenize, type CompletionContext } from "../tui/components/autocomplete";
|
|
19
|
+
import { EVOLUTION_STAGES, animateAsciiArt } from "../tui/components/ascii-art";
|
|
8
20
|
import { getEvolutionTip } from "../tui/components/evolution";
|
|
21
|
+
import { renderWelcome, playWelcomeSweep } from "../tui/components/welcome";
|
|
22
|
+
import { checkForUpdate } from "../util/update-check";
|
|
23
|
+
import { jeoEnv } from "../util/env";
|
|
24
|
+
import { renderUpdateBox } from "../tui/components/update-box";
|
|
25
|
+
import { supportsUnicode } from "../tui/components/capability";
|
|
26
|
+
import pkg from "../../package.json";
|
|
9
27
|
import chalk from "chalk";
|
|
10
|
-
import type
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import
|
|
28
|
+
import { callLlm, type Message } from "../agent/loop";
|
|
29
|
+
import { friendlyProviderError } from "../util/provider-error";
|
|
30
|
+
import { readGlobalConfig, saveConfigPatch } from "../agent/state";
|
|
31
|
+
import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recency";
|
|
32
|
+
import { describeModel, describeAllProviders, thinkingMaxTokens, discoverModels, flattenModels, resolveSelection, catalogMetadata, resolveRoleModel, CODEX_MODELS, qualifyModelId } from "../ai";
|
|
33
|
+
import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
|
|
14
34
|
|
|
15
35
|
import { listAliases } from "../ai/model-registry";
|
|
16
36
|
|
|
17
|
-
import {
|
|
37
|
+
import { allSubagentRoles, getSubagentRole, resolveSubagentModel, resolveSubagentMaxSteps, resolveSubagentThinking, parseMaxSteps, withSubagentSetting, clearSubagentSetting } from "../agent/subagents";
|
|
38
|
+
import { SelectList, renderSelectList, type SelectItem } from "../tui/components/select-list";
|
|
18
39
|
import {
|
|
19
40
|
formatModelLine,
|
|
20
|
-
formatAliasLines,
|
|
21
41
|
formatProviderPanel,
|
|
22
42
|
formatAgentsPanel,
|
|
23
43
|
formatAgentDetail,
|
|
24
44
|
formatConfigPanel,
|
|
25
45
|
|
|
26
46
|
liveModelKnown,
|
|
27
|
-
|
|
47
|
+
formatPickListWithCapabilities,
|
|
28
48
|
formatCapabilityLine,
|
|
29
|
-
formatEnrichedModels,
|
|
30
49
|
} from "../tui/components/config-panel";
|
|
31
|
-
import {
|
|
50
|
+
import { liveModelPicker, renderLiveModelPicker, type ModelAssignmentBadge } from "../tui/components/live-model-picker";
|
|
51
|
+
import { skillPicker, renderSkillPicker } from "../tui/components/skill-picker";
|
|
52
|
+
import { providerPicker, renderProviderPicker } from "../tui/components/provider-picker";
|
|
53
|
+
import { detectLanguage, languageLabel, parseLineRange, sliceLines, formatCodeBlock, formatDiff, sanitizeForTerminal } from "../tui/components/code-view";
|
|
54
|
+
import { categoryBadge } from "../tui/components/category-index";
|
|
55
|
+
import { renderInputFrame } from "../tui/components/input-box";
|
|
56
|
+
import { renderStatusBar } from "../tui/components/status";
|
|
57
|
+
import { detectColorLevel, ColorLevel } from "../tui/components/color";
|
|
58
|
+
import { readClipboardImage } from "../util/clipboard-image";
|
|
59
|
+
import { formatTranscript } from "../tui/components/transcript";
|
|
60
|
+
import type { ImageAttachment } from "../ai/types";
|
|
61
|
+
import { renderMarkdownTables } from "../tui/components/markdown-table";
|
|
62
|
+
|
|
63
|
+
import { stripMarkdown } from "../tui/components/markdown-text";
|
|
64
|
+
import { summarizeForgeInvocation } from "../tui/components/forge";
|
|
65
|
+
import { formatDuration, formatUsage } from "../tui/components/duration";
|
|
66
|
+
|
|
32
67
|
import { findTool, searchTool } from "../agent/tools";
|
|
33
68
|
import { loadProjectContext, withProjectContext } from "../agent/context-files";
|
|
34
|
-
import { maybeCompact } from "../agent/compaction";
|
|
69
|
+
import { maybeCompact, historyTokens } from "../agent/compaction";
|
|
35
70
|
import * as path from "node:path";
|
|
36
71
|
import * as fs from "node:fs";
|
|
72
|
+
import { listThemes, resolveTheme, themeGradient, accentPaint, accentShadowPaint } from "../tui/components/themes";
|
|
37
73
|
import {
|
|
38
74
|
createSession,
|
|
39
75
|
appendMessage,
|
|
76
|
+
appendMessages,
|
|
40
77
|
loadSession,
|
|
41
78
|
listSessions,
|
|
42
79
|
latestSessionId,
|
|
80
|
+
exportSession,
|
|
81
|
+
renameSession,
|
|
82
|
+
deleteSession,
|
|
83
|
+
sessionPath,
|
|
84
|
+
appendCompaction,
|
|
43
85
|
} from "../agent/session";
|
|
86
|
+
import { clearLine, cursorUp, toColumn, truncate as truncateAnsi, size as terminalSize, resetMouseTracking } from "../tui/terminal";
|
|
44
87
|
|
|
45
|
-
interface LaunchFlags {
|
|
88
|
+
export interface LaunchFlags {
|
|
46
89
|
list: boolean;
|
|
47
90
|
resume: boolean;
|
|
48
91
|
resumeId?: string;
|
|
49
92
|
noSession: boolean;
|
|
50
93
|
noTui: boolean;
|
|
94
|
+
/** Explicit step cap from --max-steps; 0 = dynamic (process-driven budget that
|
|
95
|
+
* keeps extending while the turn shows progress — no hardcoded step ceiling). */
|
|
51
96
|
maxSteps: number;
|
|
52
97
|
message: string;
|
|
53
98
|
tmux: boolean;
|
|
54
99
|
worktree?: string;
|
|
100
|
+
model?: string;
|
|
101
|
+
provider?: ProviderName;
|
|
102
|
+
modelRole?: ModelRole;
|
|
103
|
+
thinking?: ThinkLevel;
|
|
104
|
+
errors: string[];
|
|
105
|
+
print?: boolean;
|
|
106
|
+
appendSystemPromptRaw?: string;
|
|
107
|
+
appendSystemPrompt?: string;
|
|
108
|
+
noSkills: boolean;
|
|
109
|
+
skills?: string;
|
|
110
|
+
noTools: boolean;
|
|
111
|
+
tools?: string;
|
|
112
|
+
systemPromptRaw?: string;
|
|
113
|
+
systemPrompt?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const PROVIDER_DEFAULT: Record<ProviderName, string> = { anthropic: "sonnet", openai: "gpt-5.5", gemini: "flash", antigravity: "antigravity/gemini-3-pro-high", ollama: "fast" };
|
|
117
|
+
|
|
118
|
+
function takeValue(args: string[], index: number, inlinePrefix: string): { value?: string; nextIndex: number } {
|
|
119
|
+
const current = args[index]!;
|
|
120
|
+
if (current.startsWith(inlinePrefix)) return { value: current.slice(inlinePrefix.length), nextIndex: index };
|
|
121
|
+
const next = args[index + 1];
|
|
122
|
+
if (next && !next.startsWith("-")) return { value: next, nextIndex: index + 1 };
|
|
123
|
+
return { nextIndex: index };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isProviderName(input: string | undefined): input is ProviderName {
|
|
127
|
+
return input === "anthropic" || input === "openai" || input === "gemini" || input === "antigravity" || input === "ollama";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isThinkingLevel(input: string | undefined): input is ThinkLevel {
|
|
131
|
+
return input === "minimal" || input === "low" || input === "medium" || input === "high" || input === "xhigh";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function fastThinkingLevelForModel(modelId: string): ThinkLevel | undefined {
|
|
135
|
+
const supported = catalogMetadata(modelId)?.thinking ?? [];
|
|
136
|
+
if (supported.includes("minimal")) return "minimal";
|
|
137
|
+
if (supported.includes("low")) return "low";
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function hashString(input: string): string {
|
|
142
|
+
let hash = 2166136261;
|
|
143
|
+
for (let i = 0; i < input.length; i++) {
|
|
144
|
+
hash ^= input.charCodeAt(i);
|
|
145
|
+
hash = Math.imul(hash, 16777619);
|
|
146
|
+
}
|
|
147
|
+
return (hash >>> 0).toString(36).padStart(6, "0").slice(0, 6);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function tmuxSafeNamePart(input: string, max = 32): string {
|
|
151
|
+
const safe = input.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "value";
|
|
152
|
+
if (safe.length <= max) return safe;
|
|
153
|
+
return `${safe.slice(0, Math.max(1, max - 7))}-${hashString(input)}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function tmuxRuntimeSuffix(flags: LaunchFlags): string {
|
|
157
|
+
const parts: string[] = [];
|
|
158
|
+
if (flags.provider) parts.push(`provider-${flags.provider}`);
|
|
159
|
+
if (flags.model) parts.push(`model-${tmuxSafeNamePart(flags.model)}`);
|
|
160
|
+
else if (flags.modelRole) parts.push(flags.modelRole);
|
|
161
|
+
if (flags.thinking) parts.push(`think-${flags.thinking}`);
|
|
162
|
+
// Only an EXPLICIT --max-steps cap names the session; the dynamic default (0) adds nothing.
|
|
163
|
+
if (flags.maxSteps > 0) parts.push(`steps-${flags.maxSteps}`);
|
|
164
|
+
if (parts.length === 0) return "";
|
|
165
|
+
const joined = parts.join("-");
|
|
166
|
+
const suffix = joined.length <= 72 ? joined : `${joined.slice(0, 65)}-${hashString(joined)}`;
|
|
167
|
+
return `-${suffix}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Base tmux session name for `jeo --tmux`. Keyed on the working DIRECTORY (not just the
|
|
172
|
+
* git branch) so two different projects/worktrees on the same branch (e.g. `main`)
|
|
173
|
+
* never share a base. {@link uniqueTmuxSessionName} then makes each concurrent invocation
|
|
174
|
+
* fully independent, so a second `jeo --tmux` never attaches to (and mirrors) the first.
|
|
175
|
+
*/
|
|
176
|
+
export function tmuxSessionName(cwd: string, branch: string, flags: LaunchFlags): string {
|
|
177
|
+
const dirTag = `${tmuxSafeNamePart(path.basename(cwd) || "root", 16)}-${hashString(cwd)}`;
|
|
178
|
+
const base = branch ? `jeo-${branch}-${dirTag}` : `jeo-${dirTag}`;
|
|
179
|
+
return base + tmuxRuntimeSuffix(flags);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Count uncommitted git entries for the `⑂ <branch> ?N` footer dirty flag (gjc parity).
|
|
184
|
+
* One `git status --porcelain` spawn per CALL; callers invoke it once per turn start, not
|
|
185
|
+
* per render. Returns undefined when not a repo / git absent / clean.
|
|
186
|
+
*/
|
|
187
|
+
export function gitDirtyCount(cwd: string): number | undefined {
|
|
188
|
+
try {
|
|
189
|
+
const res = Bun.spawnSync(["git", "status", "--porcelain"], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
190
|
+
if (res.exitCode !== 0) return undefined;
|
|
191
|
+
const n = res.stdout.toString().split("\n").filter(l => l.trim().length > 0).length;
|
|
192
|
+
return n || undefined;
|
|
193
|
+
} catch {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Allocate + create an INDEPENDENT tmux session from a base name. Each separate,
|
|
200
|
+
* concurrent `jeo --tmux` invocation gets its OWN session instead of attaching to (and
|
|
201
|
+
* mirroring) one another process already created: try `base`, then `base-2`, `base-3`, …
|
|
202
|
+
* The create itself is the guard, so this is race-safe — two processes starting at the
|
|
203
|
+
* same instant can't both win `base`. `tryCreate` must attempt to create the named session
|
|
204
|
+
* and return `"ok"` (created — it's ours), `"taken"` (name already live / lost the race →
|
|
205
|
+
* try the next suffix), or `"error:<msg>"` (a real failure → abort). Sessions die with
|
|
206
|
+
* their jeo process, so a sequential re-run reuses the clean base; only live overlap is
|
|
207
|
+
* suffixed.
|
|
208
|
+
*/
|
|
209
|
+
export type TmuxCreateResult = "ok" | "taken" | `error:${string}`;
|
|
210
|
+
export function allocateTmuxSession(
|
|
211
|
+
base: string,
|
|
212
|
+
tryCreate: (name: string) => TmuxCreateResult,
|
|
213
|
+
): { name: string } | { error: string } {
|
|
214
|
+
for (let n = 1; n <= 1000; n++) {
|
|
215
|
+
const candidate = n === 1 ? base : `${base}-${n}`;
|
|
216
|
+
const result = tryCreate(candidate);
|
|
217
|
+
if (result === "ok") return { name: candidate };
|
|
218
|
+
if (result === "taken") continue;
|
|
219
|
+
return { error: result.slice("error:".length) };
|
|
220
|
+
}
|
|
221
|
+
return { error: `could not allocate a free tmux session name for ${base} (1000 already live?)` };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function shellQuote(arg: string): string {
|
|
225
|
+
return `'${arg.replace(/'/g, `'\\''`)}'`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* True when `jeo --tmux` runs INSIDE an existing tmux session and should enable
|
|
230
|
+
* session-scoped mouse mode for the CURRENT session: no jeo-owned session is created
|
|
231
|
+
* on this path, so without `mouse on` tmux ignores the wheel entirely and the
|
|
232
|
+
* mid-turn scrollback (ledger lines flushed above the live frame) is unreachable.
|
|
233
|
+
* Skipped for jeo-spawned sessions (JEO_TMUX_LAUNCHED=1 — the creator already set
|
|
234
|
+
* it) and when JEO_TMUX_MOUSE=0 opts out.
|
|
235
|
+
*/
|
|
236
|
+
export function shouldEnableCurrentTmuxMouse(env: Record<string, string | undefined>): boolean {
|
|
237
|
+
return !!env.TMUX
|
|
238
|
+
&& (env.JEO_TMUX_LAUNCHED ?? env.JEO_TMUX_LAUNCHED) !== "1"
|
|
239
|
+
&& (env.JEO_TMUX_MOUSE ?? env.JEO_TMUX_MOUSE) !== "0";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The runnable command for the INNER `jeo launch` a `--tmux` session executes.
|
|
244
|
+
* Pure — testable. Three runtime shapes:
|
|
245
|
+
* - compiled standalone binary: `argv[1]` is a Bun VIRTUAL path (`/$bunfs/…`)
|
|
246
|
+
* that does not exist on disk; the binary itself (`execPath`) is the
|
|
247
|
+
* entrypoint. Passing the virtual path made the inner command crash on
|
|
248
|
+
* spawn, so `tmux new-session` died instantly and the follow-up attach
|
|
249
|
+
* failed with "can't find session".
|
|
250
|
+
* - source run (`bun src/cli.ts`): re-run the script through the runtime.
|
|
251
|
+
* - anything else (a shim/binary path on disk): run it directly.
|
|
252
|
+
*/
|
|
253
|
+
export function tmuxLaunchCommand(argv1: string | undefined, execPath: string, cwd: string): string[] {
|
|
254
|
+
const entrypoint = argv1 ?? "";
|
|
255
|
+
if (entrypoint === "" || entrypoint.startsWith("/$bunfs/") || entrypoint.startsWith("B:\\~BUN\\")) {
|
|
256
|
+
return [execPath];
|
|
257
|
+
}
|
|
258
|
+
const resolved = path.isAbsolute(entrypoint) ? entrypoint : path.resolve(cwd, entrypoint);
|
|
259
|
+
if (/\.(ts|js|mjs)$/.test(entrypoint)) return [execPath, resolved];
|
|
260
|
+
return [resolved];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** One tmux configuration step applied to a jeo-owned session after creation. */
|
|
264
|
+
export interface TmuxProfileCommand {
|
|
265
|
+
description: string;
|
|
266
|
+
args: string[];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* gjc-parity tmux profile for jeo-OWNED sessions (mirrors gjc's
|
|
271
|
+
* `buildGjcTmuxProfileCommands`). Applied right after `new-session`, before attach:
|
|
272
|
+
* - `mouse on` (session-scoped): wheel-up enters copy-mode over the REAL pane
|
|
273
|
+
* history — this is what makes the mid-turn scrollback (ledger lines flushed
|
|
274
|
+
* above the inline live frame) reachable with the mouse wheel. Wheel-down at
|
|
275
|
+
* the bottom drops back out. `JEO_TMUX_MOUSE=0` opts out.
|
|
276
|
+
* - ownership/identity markers (`@jeo-profile`, `@jeo-branch`, `@jeo-project`):
|
|
277
|
+
* lets tooling tell jeo-owned sessions apart from user sessions (gjc parity
|
|
278
|
+
* with `@gjc-*`). Never applied to foreign sessions.
|
|
279
|
+
* - `set-clipboard on` + a readable copy-mode `mode-style`: text selected while
|
|
280
|
+
* wheel-scrolled back is visibly highlighted and lands on the system clipboard
|
|
281
|
+
* (OSC52). `JEO_TMUX_PROFILE=0` opts out of these cosmetic extras while keeping
|
|
282
|
+
* mouse + markers.
|
|
283
|
+
*/
|
|
284
|
+
export function tmuxProfileCommands(
|
|
285
|
+
target: string,
|
|
286
|
+
env: Record<string, string | undefined>,
|
|
287
|
+
meta: { branch?: string; project?: string } = {},
|
|
288
|
+
): TmuxProfileCommand[] {
|
|
289
|
+
// Exact-name session target. `=name:` (explicit session:window form), NOT bare
|
|
290
|
+
// `=name`: tmux 3.6 rejects bare `=name` for set-option/show-options with
|
|
291
|
+
// "no such session" even while the session is live (has-session/attach accept
|
|
292
|
+
// it). The trailing colon makes cmd-find parse `=name` as the session part —
|
|
293
|
+
// exact-matched, never prefix-matched — and resolves the session's current
|
|
294
|
+
// window for set-window-option. This was the silent failure that left
|
|
295
|
+
// `mouse on` unset, killing wheel-up scrollback in jeo-owned sessions.
|
|
296
|
+
const t = `=${target}:`;
|
|
297
|
+
const commands: TmuxProfileCommand[] = [];
|
|
298
|
+
if ((env.JEO_TMUX_MOUSE ?? env.JEO_TMUX_MOUSE) !== "0") {
|
|
299
|
+
commands.push({
|
|
300
|
+
description: "enable tmux mouse scrolling (wheel-up → copy-mode over real history)",
|
|
301
|
+
args: ["set-option", "-t", t, "mouse", "on"],
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
commands.push({
|
|
305
|
+
description: "mark jeo tmux ownership",
|
|
306
|
+
args: ["set-option", "-t", t, "@jeo-profile", "1"],
|
|
307
|
+
});
|
|
308
|
+
if (meta.branch) {
|
|
309
|
+
commands.push({
|
|
310
|
+
description: "record jeo branch identity",
|
|
311
|
+
args: ["set-option", "-t", t, "@jeo-branch", meta.branch],
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
if (meta.project) {
|
|
315
|
+
commands.push({
|
|
316
|
+
description: "record jeo project identity",
|
|
317
|
+
args: ["set-option", "-t", t, "@jeo-project", meta.project],
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
if ((env.JEO_TMUX_PROFILE ?? env.JEO_TMUX_PROFILE) !== "0") {
|
|
321
|
+
commands.push(
|
|
322
|
+
{
|
|
323
|
+
description: "enable tmux clipboard integration",
|
|
324
|
+
args: ["set-option", "-t", t, "set-clipboard", "on"],
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
description: "make copy-mode selection readable",
|
|
328
|
+
args: ["set-window-option", "-t", t, "mode-style", "fg=colour231,bg=colour60"],
|
|
329
|
+
},
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
return commands;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* A `process.stdout` view whose visible-output methods become no-ops while `gated()` is
|
|
337
|
+
* true. Used as readline's `output` so that, while the boxed slash-preview footer is armed,
|
|
338
|
+
* readline's OWN prompt/echo is suppressed and only our box is visible — no duplicated raw
|
|
339
|
+
* `jeo>` line. The previous approach monkeypatched `rl._writeToOutput`, a Node internal Bun
|
|
340
|
+
* does not expose (so on Bun both inputs showed at once). Gating the shared `output` stream
|
|
341
|
+
* works on both runtimes. Our footer is written straight to `process.stdout`, never through
|
|
342
|
+
* this proxy, so it always renders. Geometry/everything else is forwarded unchanged.
|
|
343
|
+
*/
|
|
344
|
+
const GATED_OUTPUT_METHODS = new Set(["write", "cursorTo", "moveCursor", "clearLine", "clearScreenDown", "_write", "_writev"]);
|
|
345
|
+
export function gatedStdout(real: NodeJS.WriteStream, gated: () => boolean): NodeJS.WriteStream {
|
|
346
|
+
return new Proxy(real, {
|
|
347
|
+
get(target, prop, _receiver) {
|
|
348
|
+
if (typeof prop === "string" && GATED_OUTPUT_METHODS.has(prop)) {
|
|
349
|
+
return (...args: any[]) => {
|
|
350
|
+
if (gated()) {
|
|
351
|
+
const cb = args[args.length - 1]; // honor readline's write callback so it never stalls
|
|
352
|
+
if (typeof cb === "function") cb();
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
return (target as any)[prop](...args);
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
const value = Reflect.get(target, prop, target);
|
|
359
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
360
|
+
},
|
|
361
|
+
}) as unknown as NodeJS.WriteStream;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function firstOutputLine(output: string | undefined): string {
|
|
365
|
+
if (!output) return "";
|
|
366
|
+
const line = String(output)
|
|
367
|
+
.split("\n")
|
|
368
|
+
.map(l => l.trim())
|
|
369
|
+
.find(l => l.length > 0);
|
|
370
|
+
return line ? line.replace(/\s+/g, " ").slice(0, 140) : "";
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function streamResultSuffix(tool: string, ok: boolean, output: string | undefined): string {
|
|
374
|
+
const summary = firstOutputLine(output);
|
|
375
|
+
if (!summary) return "";
|
|
376
|
+
if (!ok || tool === "task") return ` — ${summary}`;
|
|
377
|
+
return "";
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function formatTaskSubEvent(e: TaskSubEvent): string {
|
|
381
|
+
const role = e.role || "subagent";
|
|
382
|
+
const roleLabel = role.toUpperCase();
|
|
383
|
+
const detail = firstOutputLine(e.detail);
|
|
384
|
+
const summary = e.summary ? ` — ${e.summary}` : "";
|
|
385
|
+
// No ` step N/M` marker — step counters carry no meaning under the dynamic
|
|
386
|
+
// budget (user feedback); a tree prefix makes nested subagent activity scan as
|
|
387
|
+
// one readable branch in plain logs and TUI scrollback.
|
|
388
|
+
const badge = categoryBadge("subagent");
|
|
389
|
+
if (e.kind === "start") return `${badge} ${chalk.magenta(`▸ ${roleLabel}`)} · ${detail}`.slice(0, 240);
|
|
390
|
+
if (e.kind === "step") return ` ${badge} ${chalk.cyan(`├─ ${roleLabel}`)} · ${detail || "working"}`;
|
|
391
|
+
if (e.kind === "tool") return ` ${badge} ${e.success === false ? chalk.red("├─") : chalk.green("├─")} ${roleLabel} ${e.success === false ? chalk.red("✗") : chalk.green("✓")} ${detail || "tool"}${summary}`;
|
|
392
|
+
if (e.kind === "error") return ` ${badge} ${chalk.red("├─")} ${roleLabel} ${chalk.red("✗")} ${detail || "error"}`;
|
|
393
|
+
return `${badge} ${e.success === false ? chalk.red("└─") : chalk.green("└─")} ${roleLabel} done${e.success === false ? " (incomplete)" : ""}${detail ? `: ${detail}` : ""}`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function logTaskSubEvent(e: TaskSubEvent, log: (line: string) => void = (s: string) => console.log(s)): void {
|
|
397
|
+
log(formatTaskSubEvent(e));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Plain (non-TTY / `--no-tui`) progress sink — the cmd-mode equivalent of the live TUI, and
|
|
403
|
+
* the gjc-parity fix for "I typed a request but saw no steps/results". The old sink only
|
|
404
|
+
* logged tool RESULTS, so a turn that finished without a tool call (or before the first
|
|
405
|
+
* result) printed nothing but the final reply. This surfaces every STEP, the tool it is about
|
|
406
|
+
* to run (with the real file/command target via `summarizeForgeInvocation`), and each result —
|
|
407
|
+
* tracking the current step + pending invocation across the engine's
|
|
408
|
+
* onStep → onAssistant → onToolResult sequence.
|
|
409
|
+
*/
|
|
410
|
+
export function createStreamEvents(
|
|
411
|
+
_maxSteps: number,
|
|
412
|
+
log: (line: string) => void = (s: string) => console.log(s),
|
|
413
|
+
now: () => number = Date.now,
|
|
414
|
+
): AgentLoopEvents {
|
|
415
|
+
let pending = "";
|
|
416
|
+
let latestUsage: { inputTokens: number; outputTokens: number } | undefined;
|
|
417
|
+
const startTime = now();
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
// Lazy header: nothing is printed until the tool call is known (onAssistant).
|
|
421
|
+
// A `done` / invalid reply therefore emits no progress line at all. The step
|
|
422
|
+
// NUMBER itself is never shown — meaningless under the dynamic budget.
|
|
423
|
+
onStep: () => {},
|
|
424
|
+
onAssistant: (_raw: string, invocation: { tool?: string; arguments?: unknown } | null) => {
|
|
425
|
+
const tool = typeof invocation?.tool === "string" ? invocation.tool.trim() : "";
|
|
426
|
+
if (!tool || tool === "done") return;
|
|
427
|
+
pending = summarizeForgeInvocation(tool, invocation?.arguments).title;
|
|
428
|
+
// gjc-style live status unit: step header + tool target + elapsed + token usage.
|
|
429
|
+
const elapsedMs = now() - startTime;
|
|
430
|
+
let suffix = "";
|
|
431
|
+
if (elapsedMs >= 1000) suffix += ` · ${formatDuration(elapsedMs)}`;
|
|
432
|
+
if (latestUsage) suffix += ` · ${formatUsage(latestUsage)}`;
|
|
433
|
+
log(`${categoryBadge("progress")} ${pending}${suffix ? chalk.dim(suffix) : ""}`);
|
|
434
|
+
},
|
|
435
|
+
onToolResult: (tool: string, ok: boolean, output?: string) => {
|
|
436
|
+
const label = pending || tool;
|
|
437
|
+
const mark = ok ? chalk.green("✓") : chalk.red("✗");
|
|
438
|
+
log(` ${categoryBadge(ok ? "done" : "error")} ${mark} ${label}${streamResultSuffix(tool, ok, output)}`);
|
|
439
|
+
pending = "";
|
|
440
|
+
},
|
|
441
|
+
onNotice: (msg: string) => log(` ${categoryBadge("progress")} ${chalk.yellow(msg)}`),
|
|
442
|
+
onBudget: (_limit: number, reason: string) => {
|
|
443
|
+
log(` ${categoryBadge("progress")} ${chalk.yellow(reason)}`);
|
|
444
|
+
},
|
|
445
|
+
onUsage: (usage: { inputTokens: number; outputTokens: number }) => {
|
|
446
|
+
latestUsage = usage;
|
|
447
|
+
},
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function shouldUseOneShotTui(noTui: boolean): boolean {
|
|
452
|
+
return LaunchTui.usable(noTui);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export interface InFlightAbortHarness {
|
|
456
|
+
controller: AbortController;
|
|
457
|
+
handleSigint(): void;
|
|
458
|
+
handleData(chunk: string | Uint8Array): void;
|
|
459
|
+
dispose(): void;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
interface AbortHarnessOptions {
|
|
463
|
+
controller?: AbortController;
|
|
464
|
+
captureEsc?: boolean;
|
|
465
|
+
stdin?: {
|
|
466
|
+
isTTY?: boolean;
|
|
467
|
+
isRaw?: boolean;
|
|
468
|
+
setRawMode?(raw: boolean): void;
|
|
469
|
+
resume?(): void;
|
|
470
|
+
on(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
|
|
471
|
+
off(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
|
|
472
|
+
};
|
|
473
|
+
onAbortNotice?: (message: string) => void;
|
|
474
|
+
onHardExit?: () => void;
|
|
475
|
+
/** Invoked when stray escape-sequence noise (wheel scroll etc.) arrives mid-turn. */
|
|
476
|
+
onNoise?: () => void;
|
|
477
|
+
/** Invoked when Ctrl+O (\u000f) is pressed mid-turn — the detail-view binding.
|
|
478
|
+
* Without this hook the byte would be swallowed into the buffered input queue,
|
|
479
|
+
* which is why Ctrl+O historically "did nothing" while the TUI owned stdin. */
|
|
480
|
+
onDetailKey?: () => void;
|
|
481
|
+
/** Invoked with printable keyboard input received while the live turn owns stdin. */
|
|
482
|
+
onBufferedInput?: (chunk: string) => void;
|
|
483
|
+
/** True while the input queue is inside a bracketed paste (mid-paste chunks
|
|
484
|
+
* carry no marker and must keep routing to the queue, not the noise path). */
|
|
485
|
+
pasteActive?: () => boolean;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Bracketed-paste markers (DECSET 2004): terminals wrap pasted text in these so
|
|
489
|
+
* an app can treat the paste as DATA instead of keystrokes — the prompt_toolkit
|
|
490
|
+
* paste contract. jeo enables the mode for the REPL TTY so a multi-line paste
|
|
491
|
+
* arrives atomically and executes one command per line, in order. */
|
|
492
|
+
export const PASTE_START = "\u001b[200~";
|
|
493
|
+
export const PASTE_END = "\u001b[201~";
|
|
494
|
+
|
|
495
|
+
export interface PromptInputQueue {
|
|
496
|
+
pendingLines: string[];
|
|
497
|
+
partial: string;
|
|
498
|
+
/** Complete lines that arrived inside a bracketed PASTE: intentional batch
|
|
499
|
+
* commands, served one per prompt in order. Never folded into the typed-line
|
|
500
|
+
* prefill (that contract is for keystrokes typed during a live turn). */
|
|
501
|
+
pastedLines: string[];
|
|
502
|
+
/** True while a bracketed paste spans chunks (between \x1b[200~ and \x1b[201~). */
|
|
503
|
+
inPaste: boolean;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Typed (non-paste) keystrokes: printable chars build the partial, Enter promotes
|
|
507
|
+
* it to pendingLines, backspace edits — ESC/ctrl noise segments are rejected. */
|
|
508
|
+
function feedTypedSegment(state: PromptInputQueue, segment: string): boolean {
|
|
509
|
+
if (!segment || segment.includes("\u001b") || segment.includes("\u0003")) return false;
|
|
510
|
+
let accepted = false;
|
|
511
|
+
const normalized = segment.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
512
|
+
for (const ch of Array.from(normalized)) {
|
|
513
|
+
if (ch === "\n") {
|
|
514
|
+
if (state.partial.length > 0) accepted = true;
|
|
515
|
+
state.pendingLines.push(state.partial);
|
|
516
|
+
state.partial = "";
|
|
517
|
+
} else if (ch === "\u007f" || ch === "\b") {
|
|
518
|
+
const chars = Array.from(state.partial);
|
|
519
|
+
chars.pop();
|
|
520
|
+
state.partial = chars.join("");
|
|
521
|
+
accepted = true;
|
|
522
|
+
} else if (ch === "\t" || ch >= " ") {
|
|
523
|
+
state.partial += ch;
|
|
524
|
+
accepted = true;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return accepted;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Pasted body: pure DATA — newlines split commands into pastedLines, the trailing
|
|
531
|
+
* partial stays editable, and control bytes (incl. any stray ESC from copied ANSI
|
|
532
|
+
* text) are dropped instead of being interpreted as keystrokes. */
|
|
533
|
+
function feedPasteBody(state: PromptInputQueue, body: string): boolean {
|
|
534
|
+
if (!body) return false;
|
|
535
|
+
let accepted = false;
|
|
536
|
+
const normalized = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
537
|
+
for (const ch of Array.from(normalized)) {
|
|
538
|
+
if (ch === "\n") {
|
|
539
|
+
state.pastedLines.push(state.partial);
|
|
540
|
+
state.partial = "";
|
|
541
|
+
accepted = true;
|
|
542
|
+
} else if (ch === "\t" || ch >= " ") {
|
|
543
|
+
state.partial += ch;
|
|
544
|
+
accepted = true;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return accepted;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export function queuePromptInputChunk(state: PromptInputQueue, chunk: string): boolean {
|
|
551
|
+
if (!chunk) return false;
|
|
552
|
+
let accepted = false;
|
|
553
|
+
let rest = chunk;
|
|
554
|
+
while (rest.length > 0) {
|
|
555
|
+
if (state.inPaste) {
|
|
556
|
+
const end = rest.indexOf(PASTE_END);
|
|
557
|
+
const body = end === -1 ? rest : rest.slice(0, end);
|
|
558
|
+
if (end !== -1) state.inPaste = false;
|
|
559
|
+
rest = end === -1 ? "" : rest.slice(end + PASTE_END.length);
|
|
560
|
+
if (feedPasteBody(state, body)) accepted = true;
|
|
561
|
+
} else {
|
|
562
|
+
const start = rest.indexOf(PASTE_START);
|
|
563
|
+
const plain = start === -1 ? rest : rest.slice(0, start);
|
|
564
|
+
if (start !== -1) state.inPaste = true;
|
|
565
|
+
rest = start === -1 ? "" : rest.slice(start + PASTE_START.length);
|
|
566
|
+
if (feedTypedSegment(state, plain)) accepted = true;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return accepted;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Live-turn prompt capture: printable input edits the SAME next-prompt line the
|
|
573
|
+
* idle footer will show after the turn finishes. Enter does NOT promote a hidden
|
|
574
|
+
* queue entry; it merely marks the current line as ready, so the existing input
|
|
575
|
+
* box stays the single source of truth and the user presses Enter once more at
|
|
576
|
+
* the real prompt to run it. */
|
|
577
|
+
function feedLivePromptSegment(state: PromptInputQueue, segment: string): boolean {
|
|
578
|
+
if (!segment || segment.includes("\u001b") || segment.includes("\u0003")) return false;
|
|
579
|
+
let accepted = false;
|
|
580
|
+
const normalized = segment.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
581
|
+
for (const ch of Array.from(normalized)) {
|
|
582
|
+
if (ch === "\n") {
|
|
583
|
+
accepted = true;
|
|
584
|
+
} else if (ch === "\u007f" || ch === "\b") {
|
|
585
|
+
const chars = Array.from(state.partial);
|
|
586
|
+
chars.pop();
|
|
587
|
+
state.partial = chars.join("");
|
|
588
|
+
accepted = true;
|
|
589
|
+
} else if (ch === "\t" || ch >= " ") {
|
|
590
|
+
state.partial += ch;
|
|
591
|
+
accepted = true;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return accepted;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function feedLivePromptPasteBody(state: PromptInputQueue, body: string): boolean {
|
|
598
|
+
if (!body) return false;
|
|
599
|
+
const normalized = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
600
|
+
const flattened = normalized.split("\n").map(part => part.trim()).filter(Boolean).join(" ");
|
|
601
|
+
if (!flattened) return false;
|
|
602
|
+
state.partial = state.partial ? `${state.partial} ${flattened}` : flattened;
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
export function captureLivePromptInputChunk(state: PromptInputQueue, chunk: string): boolean {
|
|
607
|
+
if (!chunk) return false;
|
|
608
|
+
let accepted = false;
|
|
609
|
+
let rest = chunk;
|
|
610
|
+
while (rest.length > 0) {
|
|
611
|
+
if (state.inPaste) {
|
|
612
|
+
const end = rest.indexOf(PASTE_END);
|
|
613
|
+
const body = end === -1 ? rest : rest.slice(0, end);
|
|
614
|
+
if (end !== -1) state.inPaste = false;
|
|
615
|
+
rest = end === -1 ? "" : rest.slice(end + PASTE_END.length);
|
|
616
|
+
if (feedLivePromptPasteBody(state, body)) accepted = true;
|
|
617
|
+
} else {
|
|
618
|
+
const start = rest.indexOf(PASTE_START);
|
|
619
|
+
const plain = start === -1 ? rest : rest.slice(0, start);
|
|
620
|
+
if (start !== -1) state.inPaste = true;
|
|
621
|
+
rest = start === -1 ? "" : rest.slice(start + PASTE_START.length);
|
|
622
|
+
if (feedLivePromptSegment(state, plain)) accepted = true;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return accepted;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* TTY "new input first" contract: fold any queued FULL lines (stray
|
|
630
|
+
* Enter-terminated buffer noise, or older persisted queues) into the editable
|
|
631
|
+
* prompt prefill instead of leaving them to auto-execute as the next prompt.
|
|
632
|
+
* Without this, stale queued lines ran BEFORE the user's fresh input — jeo
|
|
633
|
+
* appeared to "continue the previous work first". Returns the number of lines
|
|
634
|
+
* folded. Pure over the queue object — piped/non-TTY callers must NOT use this
|
|
635
|
+
* (scripted stdin relies on in-order line execution).
|
|
636
|
+
*/
|
|
637
|
+
export function restoreQueuedLinesToPrefill(state: PromptInputQueue): number {
|
|
638
|
+
const lines = state.pendingLines.splice(0, state.pendingLines.length).map(l => l.trim()).filter(Boolean);
|
|
639
|
+
if (lines.length === 0) return 0;
|
|
640
|
+
const restored = lines.join(" ");
|
|
641
|
+
state.partial = state.partial ? `${restored} ${state.partial}`.trim() : restored;
|
|
642
|
+
return lines.length;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export function createInFlightAbortHarness(opts: AbortHarnessOptions = {}): InFlightAbortHarness {
|
|
646
|
+
const controller = opts.controller ?? new AbortController();
|
|
647
|
+
const stdin = opts.stdin ?? process.stdin;
|
|
648
|
+
const captureEsc = opts.captureEsc === true && !!stdin.isTTY;
|
|
649
|
+
const wasRaw = !!stdin.isRaw;
|
|
650
|
+
let rawChanged = false;
|
|
651
|
+
|
|
652
|
+
const abortNow = (message: string) => {
|
|
653
|
+
if (controller.signal.aborted) return false;
|
|
654
|
+
opts.onAbortNotice?.(message);
|
|
655
|
+
controller.abort();
|
|
656
|
+
return true;
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
const handleSigint = () => {
|
|
660
|
+
// Ctrl+C is a hard terminal break. Older jeo softened the first press into
|
|
661
|
+
// "abort current run; press again to exit", which left users trapped in raw
|
|
662
|
+
// TTY/TUI states when they expected the terminal to stop. Abort the controller
|
|
663
|
+
// for cleanup observers, then invoke the hard-exit hook immediately.
|
|
664
|
+
if (!controller.signal.aborted) controller.abort();
|
|
665
|
+
opts.onHardExit?.();
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const handleData = (chunk: string | Uint8Array) => {
|
|
669
|
+
if (!captureEsc || controller.signal.aborted) return;
|
|
670
|
+
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
671
|
+
// Bracketed paste is DATA, not keystrokes (the prompt_toolkit contract):
|
|
672
|
+
// marker-carrying and mid-paste chunks go straight to the input queue BEFORE
|
|
673
|
+
// any ESC interpretation — the markers themselves contain ESC, and a paste
|
|
674
|
+
// must never be classified as cancel/noise.
|
|
675
|
+
if (text.includes(PASTE_START) || opts.pasteActive?.()) {
|
|
676
|
+
opts.onBufferedInput?.(text);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
// Ctrl+O — detail view. Exact-match like the lone-ESC cancel: a raw \u000f
|
|
680
|
+
// keystroke arrives as its own chunk; embedded \u000f inside pasted/streamed
|
|
681
|
+
// data must NOT trigger the view.
|
|
682
|
+
if (text === "\u000f") {
|
|
683
|
+
opts.onDetailKey?.();
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
const escAt = text.indexOf("\u001b");
|
|
687
|
+
const sigintAt = text.indexOf("\u0003");
|
|
688
|
+
const controlAt =
|
|
689
|
+
escAt === -1 ? sigintAt :
|
|
690
|
+
sigintAt === -1 ? escAt :
|
|
691
|
+
Math.min(escAt, sigintAt);
|
|
692
|
+
if (controlAt >= 0) {
|
|
693
|
+
const printablePrefix = text.slice(0, controlAt);
|
|
694
|
+
if (printablePrefix) opts.onBufferedInput?.(printablePrefix);
|
|
695
|
+
if (text[controlAt] === "\u0003") {
|
|
696
|
+
handleSigint();
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (text === "\u001b") {
|
|
700
|
+
abortNow("ESC pressed — cancelling current run…");
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
opts.onNoise?.();
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
opts.onBufferedInput?.(text);
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
process.on("SIGINT", handleSigint);
|
|
710
|
+
if (captureEsc) {
|
|
711
|
+
stdin.on("data", handleData);
|
|
712
|
+
if (stdin.setRawMode && !wasRaw) {
|
|
713
|
+
stdin.setRawMode(true);
|
|
714
|
+
rawChanged = true;
|
|
715
|
+
}
|
|
716
|
+
stdin.resume?.();
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
return {
|
|
720
|
+
controller,
|
|
721
|
+
handleSigint,
|
|
722
|
+
handleData,
|
|
723
|
+
dispose() {
|
|
724
|
+
process.removeListener("SIGINT", handleSigint);
|
|
725
|
+
if (captureEsc) {
|
|
726
|
+
stdin.off("data", handleData);
|
|
727
|
+
if (rawChanged) stdin.setRawMode?.(false);
|
|
728
|
+
}
|
|
729
|
+
},
|
|
730
|
+
};
|
|
55
731
|
}
|
|
56
732
|
|
|
57
|
-
|
|
58
|
-
|
|
733
|
+
/** The exact resume command printed on REPL exit (and testable in isolation) —
|
|
734
|
+
* same convention as the `--list` handler's hint. */
|
|
735
|
+
export function formatResumeHint(sessionId: string): string {
|
|
736
|
+
return `Resume with: jeo launch --resume ${sessionId}`;
|
|
737
|
+
}
|
|
738
|
+
export function parseFlags(args: string[], cwd: string = process.cwd()): LaunchFlags {
|
|
739
|
+
// maxSteps 0 = dynamic: the engine's process-driven budget extends itself while the
|
|
740
|
+
// turn shows progress instead of stopping at a hardcoded count (old default: 100).
|
|
741
|
+
const flags: LaunchFlags = { list: false, resume: false, noSession: false, noTui: false, maxSteps: 0, message: "", tmux: false, errors: [], print: false, noSkills: false, noTools: false };
|
|
59
742
|
const rest: string[] = [];
|
|
60
743
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
61
744
|
for (let i = 0; i < args.length; i++) {
|
|
62
745
|
const a = args[i];
|
|
746
|
+
if (a === "--") {
|
|
747
|
+
rest.push(...args.slice(i + 1));
|
|
748
|
+
break;
|
|
749
|
+
}
|
|
63
750
|
if (a === "--list") {
|
|
64
751
|
flags.list = true;
|
|
752
|
+
} else if (a === "-p" || a === "--print") {
|
|
753
|
+
flags.print = true;
|
|
754
|
+
flags.noTui = true;
|
|
65
755
|
} else if (a === "--tmux") {
|
|
66
756
|
flags.tmux = true;
|
|
67
757
|
} else if (a === "--worktree") {
|
|
@@ -85,28 +775,190 @@ function parseFlags(args: string[]): LaunchFlags {
|
|
|
85
775
|
} else if (a.startsWith("--max-steps=")) {
|
|
86
776
|
const n = parseInt(a.slice(12), 10);
|
|
87
777
|
if (Number.isFinite(n) && n > 0) flags.maxSteps = n;
|
|
88
|
-
} else if (a === "--
|
|
778
|
+
} else if (a === "--model") {
|
|
779
|
+
const { value, nextIndex } = takeValue(args, i, "--model=");
|
|
780
|
+
if (value) flags.model = value;
|
|
781
|
+
else flags.errors.push("--model requires a value");
|
|
782
|
+
i = nextIndex;
|
|
783
|
+
} else if (a.startsWith("--model=")) {
|
|
784
|
+
const { value } = takeValue(args, i, "--model=");
|
|
785
|
+
if (value) flags.model = value;
|
|
786
|
+
else flags.errors.push("--model requires a value");
|
|
787
|
+
} else if (a === "--provider") {
|
|
788
|
+
const { value, nextIndex } = takeValue(args, i, "--provider=");
|
|
789
|
+
const normalized = value?.toLowerCase();
|
|
790
|
+
if (isProviderName(normalized)) flags.provider = normalized;
|
|
791
|
+
else flags.errors.push("--provider must be one of: anthropic, openai, gemini, ollama");
|
|
792
|
+
i = nextIndex;
|
|
793
|
+
} else if (a.startsWith("--provider=")) {
|
|
794
|
+
const { value } = takeValue(args, i, "--provider=");
|
|
795
|
+
const normalized = value?.toLowerCase();
|
|
796
|
+
if (isProviderName(normalized)) flags.provider = normalized;
|
|
797
|
+
else flags.errors.push("--provider must be one of: anthropic, openai, gemini, ollama");
|
|
798
|
+
} else if (a === "--thinking") {
|
|
799
|
+
const { value, nextIndex } = takeValue(args, i, "--thinking=");
|
|
800
|
+
const normalized = value?.toLowerCase();
|
|
801
|
+
if (isThinkingLevel(normalized)) flags.thinking = normalized;
|
|
802
|
+
else flags.errors.push("--thinking must be one of: minimal, low, medium, high, xhigh");
|
|
803
|
+
i = nextIndex;
|
|
804
|
+
} else if (a.startsWith("--thinking=")) {
|
|
805
|
+
const { value } = takeValue(args, i, "--thinking=");
|
|
806
|
+
const normalized = value?.toLowerCase();
|
|
807
|
+
if (isThinkingLevel(normalized)) flags.thinking = normalized;
|
|
808
|
+
else flags.errors.push("--thinking must be one of: minimal, low, medium, high, xhigh");
|
|
809
|
+
} else if (a === "--smol" || a === "--slow" || a === "--plan") {
|
|
810
|
+
flags.modelRole = a.slice(2) as ModelRole;
|
|
811
|
+
} else if (a === "--resume" || a === "--continue" || a === "-c") {
|
|
89
812
|
flags.resume = true;
|
|
90
813
|
const next = args[i + 1];
|
|
91
814
|
if (next && UUID_REGEX.test(next)) {
|
|
92
815
|
flags.resumeId = next;
|
|
93
816
|
i++;
|
|
94
817
|
}
|
|
95
|
-
} else if (a.startsWith("--resume=")) {
|
|
818
|
+
} else if (a.startsWith("--resume=") || a.startsWith("--continue=") || a.startsWith("-c=")) {
|
|
96
819
|
flags.resume = true;
|
|
97
|
-
const
|
|
820
|
+
const eqIdx = a.indexOf("=");
|
|
821
|
+
const val = a.slice(eqIdx + 1);
|
|
98
822
|
if (UUID_REGEX.test(val)) {
|
|
99
823
|
flags.resumeId = val;
|
|
100
824
|
} else {
|
|
101
825
|
rest.push(val);
|
|
102
826
|
}
|
|
827
|
+
} else if (a === "--append-system-prompt") {
|
|
828
|
+
const { value, nextIndex } = takeValue(args, i, "--append-system-prompt=");
|
|
829
|
+
if (value) {
|
|
830
|
+
flags.appendSystemPromptRaw = value;
|
|
831
|
+
} else {
|
|
832
|
+
flags.errors.push("--append-system-prompt requires a value");
|
|
833
|
+
}
|
|
834
|
+
i = nextIndex;
|
|
835
|
+
} else if (a.startsWith("--append-system-prompt=")) {
|
|
836
|
+
const { value } = takeValue(args, i, "--append-system-prompt=");
|
|
837
|
+
if (value) {
|
|
838
|
+
flags.appendSystemPromptRaw = value;
|
|
839
|
+
} else {
|
|
840
|
+
flags.errors.push("--append-system-prompt requires a value");
|
|
841
|
+
}
|
|
842
|
+
} else if (a === "--no-skills") {
|
|
843
|
+
flags.noSkills = true;
|
|
844
|
+
} else if (a === "--skills") {
|
|
845
|
+
const { value, nextIndex } = takeValue(args, i, "--skills=");
|
|
846
|
+
if (value) flags.skills = value;
|
|
847
|
+
else flags.errors.push("--skills requires a value");
|
|
848
|
+
i = nextIndex;
|
|
849
|
+
} else if (a.startsWith("--skills=")) {
|
|
850
|
+
const { value } = takeValue(args, i, "--skills=");
|
|
851
|
+
if (value) flags.skills = value;
|
|
852
|
+
else flags.errors.push("--skills requires a value");
|
|
853
|
+
} else if (a === "--no-tools") {
|
|
854
|
+
flags.noTools = true;
|
|
855
|
+
} else if (a === "--tools") {
|
|
856
|
+
const { value, nextIndex } = takeValue(args, i, "--tools=");
|
|
857
|
+
if (value) flags.tools = value;
|
|
858
|
+
else flags.errors.push("--tools requires a value");
|
|
859
|
+
i = nextIndex;
|
|
860
|
+
} else if (a.startsWith("--tools=")) {
|
|
861
|
+
const { value } = takeValue(args, i, "--tools=");
|
|
862
|
+
if (value) flags.tools = value;
|
|
863
|
+
else flags.errors.push("--tools requires a value");
|
|
864
|
+
} else if (a === "--system-prompt") {
|
|
865
|
+
const { value, nextIndex } = takeValue(args, i, "--system-prompt=");
|
|
866
|
+
if (value) flags.systemPromptRaw = value;
|
|
867
|
+
else flags.errors.push("--system-prompt requires a value");
|
|
868
|
+
i = nextIndex;
|
|
869
|
+
} else if (a.startsWith("--system-prompt=")) {
|
|
870
|
+
const { value } = takeValue(args, i, "--system-prompt=");
|
|
871
|
+
if (value) flags.systemPromptRaw = value;
|
|
872
|
+
else flags.errors.push("--system-prompt requires a value");
|
|
103
873
|
} else {
|
|
104
874
|
rest.push(a);
|
|
105
875
|
}
|
|
106
876
|
}
|
|
107
877
|
flags.message = rest.join(" ").trim();
|
|
878
|
+
|
|
879
|
+
if (flags.print && !flags.message) {
|
|
880
|
+
flags.errors.push("-p/--print requires a message argument");
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (flags.appendSystemPromptRaw) {
|
|
884
|
+
if (flags.appendSystemPromptRaw.startsWith("@")) {
|
|
885
|
+
const filePath = flags.appendSystemPromptRaw.slice(1);
|
|
886
|
+
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
887
|
+
try {
|
|
888
|
+
flags.appendSystemPrompt = fs.readFileSync(absPath, "utf8");
|
|
889
|
+
} catch (err) {
|
|
890
|
+
flags.errors.push(`failed to read system prompt file: ${(err as Error).message}`);
|
|
891
|
+
}
|
|
892
|
+
} else {
|
|
893
|
+
flags.appendSystemPrompt = flags.appendSystemPromptRaw;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (flags.systemPromptRaw) {
|
|
897
|
+
if (flags.systemPromptRaw.startsWith("@")) {
|
|
898
|
+
const filePath = flags.systemPromptRaw.slice(1);
|
|
899
|
+
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
900
|
+
try {
|
|
901
|
+
flags.systemPrompt = fs.readFileSync(absPath, "utf8");
|
|
902
|
+
} catch (err) {
|
|
903
|
+
flags.errors.push(`failed to read system prompt file: ${(err as Error).message}`);
|
|
904
|
+
}
|
|
905
|
+
} else {
|
|
906
|
+
flags.systemPrompt = flags.systemPromptRaw;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
108
910
|
return flags;
|
|
109
911
|
}
|
|
912
|
+
export function matchSkillGlob(pattern: string, name: string): boolean {
|
|
913
|
+
const p = pattern.toLowerCase();
|
|
914
|
+
const n = name.toLowerCase();
|
|
915
|
+
if (!p.includes("*")) {
|
|
916
|
+
return p === n;
|
|
917
|
+
}
|
|
918
|
+
const escaped = p.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
919
|
+
const regexStr = "^" + escaped.replace(/\*/g, ".*") + "$";
|
|
920
|
+
const regex = new RegExp(regexStr);
|
|
921
|
+
return regex.test(n);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
export function filterToolMap(
|
|
925
|
+
tools: Record<string, any>,
|
|
926
|
+
allowlist: string[]
|
|
927
|
+
): Record<string, any> {
|
|
928
|
+
const result: Record<string, any> = {};
|
|
929
|
+
for (const name of allowlist) {
|
|
930
|
+
if (name in tools) {
|
|
931
|
+
result[name] = tools[name];
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
return result;
|
|
935
|
+
}
|
|
936
|
+
export const TOOL_DESCRIPTIONS: Record<string, string> = {
|
|
937
|
+
read: "read {filePath, lineRange?, raw?} — read a file; lines are prefixed `LINEhh|` (hh = 2-char content anchor; the | is a separator, not file bytes)",
|
|
938
|
+
write: "write {filePath, content} — create/overwrite a file",
|
|
939
|
+
edit: "edit {filePath, editBlock} — ≔A..B replace lines (append read anchors for safety: ≔12ab..15cd — rejected with fresh content if the lines changed); ≔A+ insert after line A; ≔$ append EOF (payload on next line). NEVER copy the `LINEhh|` prefixes into SEARCH blocks or payloads",
|
|
940
|
+
bash: "bash {command, timeoutMs?, cwd?, env?} — run a shell command (cwd: subdir; env: extra vars)",
|
|
941
|
+
find: "find {globPattern} — find files by name",
|
|
942
|
+
search: "search {pattern, globPattern?, ignoreCase?, context?, maxMatches?} — grep (context: N lines around each match)",
|
|
943
|
+
ls: "ls {dirPath} — list a directory's entries (dirs first)",
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
export function buildToolProtocol(allowedTools: Set<string>): string {
|
|
947
|
+
const lines: string[] = ["You have these tools (call exactly ONE per step):"];
|
|
948
|
+
let num = 1;
|
|
949
|
+
for (const name of ["read", "write", "edit", "bash", "find", "search", "ls"]) {
|
|
950
|
+
if (allowedTools.has(name)) {
|
|
951
|
+
lines.push(`${num}. ${TOOL_DESCRIPTIONS[name]}`);
|
|
952
|
+
num++;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
lines.push(`${num}. done {reason?} — call when the task is fully implemented AND verified`);
|
|
956
|
+
lines.push("");
|
|
957
|
+
lines.push("Reply with STRICT JSON only — no code fences. You MAY include an optional leading");
|
|
958
|
+
lines.push('"reasoning" string (one short sentence on your plan, shown live to the user) before "tool":');
|
|
959
|
+
lines.push('{ "reasoning": "<one short sentence>", "tool": "<name>", "arguments": { ... } }');
|
|
960
|
+
return lines.join("\n");
|
|
961
|
+
}
|
|
110
962
|
|
|
111
963
|
/**
|
|
112
964
|
* Resolve a git worktree path (gjc `--worktree <path>` parity). If the path
|
|
@@ -120,7 +972,7 @@ function resolveWorktree(cwd: string, wt: string): string {
|
|
|
120
972
|
console.error("error: --worktree requires git on PATH");
|
|
121
973
|
process.exit(1);
|
|
122
974
|
}
|
|
123
|
-
const branch = (path.basename(abs).replace(/[^a-zA-Z0-9_-]/g, "-") || "
|
|
975
|
+
const branch = (path.basename(abs).replace(/[^a-zA-Z0-9_-]/g, "-") || "jeo-wt");
|
|
124
976
|
const withBranch = Bun.spawnSync(["git", "worktree", "add", "-b", branch, abs], {
|
|
125
977
|
cwd,
|
|
126
978
|
stdout: "pipe",
|
|
@@ -145,18 +997,52 @@ function resolveWorktree(cwd: string, wt: string): string {
|
|
|
145
997
|
|
|
146
998
|
export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
147
999
|
let cwd = process.cwd();
|
|
148
|
-
const flags = parseFlags(args);
|
|
1000
|
+
const flags = parseFlags(args, cwd);
|
|
1001
|
+
if (flags.errors.length) {
|
|
1002
|
+
for (const err of flags.errors) {
|
|
1003
|
+
console.error(`error: ${err}`);
|
|
1004
|
+
}
|
|
1005
|
+
process.exitCode = 1;
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
149
1008
|
|
|
150
1009
|
if (flags.worktree) {
|
|
151
1010
|
const wt = resolveWorktree(cwd, flags.worktree);
|
|
152
1011
|
if (wt !== cwd) {
|
|
153
1012
|
process.chdir(wt);
|
|
154
1013
|
cwd = wt;
|
|
155
|
-
if (
|
|
1014
|
+
if (jeoEnv("TMUX_LAUNCHED") !== "1") console.log(`Using worktree: ${wt}`);
|
|
156
1015
|
}
|
|
157
1016
|
}
|
|
1017
|
+
let branch: string | undefined;
|
|
1018
|
+
try {
|
|
1019
|
+
// Same git invocation the tmux session-naming path uses (symbolic-ref is
|
|
1020
|
+
// quiet + fails cleanly on detached HEAD, so no "HEAD" placeholder leaks).
|
|
1021
|
+
const gitRes = Bun.spawnSync(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], {
|
|
1022
|
+
cwd,
|
|
1023
|
+
stdout: "pipe",
|
|
1024
|
+
stderr: "ignore",
|
|
1025
|
+
});
|
|
1026
|
+
if (gitRes.exitCode === 0) {
|
|
1027
|
+
const out = gitRes.stdout.toString().trim();
|
|
1028
|
+
branch = out || undefined;
|
|
1029
|
+
}
|
|
1030
|
+
} catch {}
|
|
1031
|
+
const cfg = await readGlobalConfig();
|
|
1032
|
+
const defaultModel = cfg.defaultModel;
|
|
1033
|
+
const initialSessionModel =
|
|
1034
|
+
flags.model ??
|
|
1035
|
+
(flags.modelRole ? resolveRoleModel(flags.modelRole, cfg) : flags.provider ? PROVIDER_DEFAULT[flags.provider] : undefined);
|
|
1036
|
+
if (flags.provider && initialSessionModel) {
|
|
1037
|
+
const { provider } = await describeModel(initialSessionModel);
|
|
1038
|
+
if (provider !== flags.provider) {
|
|
1039
|
+
console.log(`error: selected model '${initialSessionModel}' resolves to ${provider}, not requested provider ${flags.provider}.`);
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
158
1044
|
if (flags.tmux) {
|
|
159
|
-
if (!process.env.TMUX &&
|
|
1045
|
+
if (!process.env.TMUX && jeoEnv("TMUX_LAUNCHED") !== "1") {
|
|
160
1046
|
const tmuxBin = Bun.which("tmux");
|
|
161
1047
|
if (tmuxBin) {
|
|
162
1048
|
let branch = "";
|
|
@@ -170,7 +1056,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
170
1056
|
branch = gitRes.stdout.toString().trim().replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
171
1057
|
}
|
|
172
1058
|
} catch {}
|
|
173
|
-
const
|
|
1059
|
+
const sessionBase = tmuxSessionName(cwd, branch, flags);
|
|
174
1060
|
|
|
175
1061
|
// Strip orchestration flags: the worktree is already the tmux session
|
|
176
1062
|
// cwd (`-c cwd` below), so the inner process inherits it directly.
|
|
@@ -182,44 +1068,36 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
182
1068
|
if (a.startsWith("--worktree=")) continue;
|
|
183
1069
|
innerArgs.push(a);
|
|
184
1070
|
}
|
|
185
|
-
const
|
|
186
|
-
const resolvedEntrypoint = path.isAbsolute(entrypoint) ? entrypoint : path.resolve(cwd, entrypoint);
|
|
187
|
-
let cmd: string[] = [];
|
|
188
|
-
if (entrypoint.endsWith(".ts") || entrypoint.endsWith(".js") || entrypoint.endsWith(".mjs")) {
|
|
189
|
-
cmd = [process.execPath, resolvedEntrypoint];
|
|
190
|
-
} else {
|
|
191
|
-
cmd = [resolvedEntrypoint];
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const innerCmd = `exec env JOC_TMUX_LAUNCHED=1 ${[...cmd, "launch", ...innerArgs].map(a => `"${a.replace(/"/g, '\\"')}"`).join(" ")}`;
|
|
1071
|
+
const cmd = tmuxLaunchCommand(process.argv[1], process.execPath, cwd);
|
|
195
1072
|
|
|
196
|
-
const
|
|
197
|
-
if (hasSession.exitCode === 0) {
|
|
198
|
-
console.log(`Attaching to existing tmux session: ${sessionName}`);
|
|
199
|
-
const proc = Bun.spawn([tmuxBin, "attach-session", "-t", `=${sessionName}`], {
|
|
200
|
-
stdin: "inherit",
|
|
201
|
-
stdout: "inherit",
|
|
202
|
-
stderr: "inherit",
|
|
203
|
-
});
|
|
204
|
-
await proc.exited;
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
1073
|
+
const innerCmd = `exec env JEO_TMUX_LAUNCHED=1 ${[...cmd, "launch", ...innerArgs].map(shellQuote).join(" ")}`;
|
|
207
1074
|
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
tmuxBin,
|
|
211
|
-
"
|
|
212
|
-
|
|
213
|
-
"
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
]);
|
|
219
|
-
if (createSession.exitCode !== 0) {
|
|
220
|
-
console.error(`Error: Failed to create tmux session: ${createSession.stderr.toString()}`);
|
|
1075
|
+
// Create a fresh, independent session (race-safe: the create is the guard).
|
|
1076
|
+
const alloc = allocateTmuxSession(sessionBase, name => {
|
|
1077
|
+
const created = Bun.spawnSync([tmuxBin, "new-session", "-d", "-s", name, "-c", cwd, innerCmd]);
|
|
1078
|
+
if (created.exitCode === 0) return "ok";
|
|
1079
|
+
const err = created.stderr.toString().trim();
|
|
1080
|
+
if (/duplicate session/i.test(err)) return "taken"; // another jeo grabbed this name
|
|
1081
|
+
return `error:${err || `tmux new-session exited ${created.exitCode}`}`;
|
|
1082
|
+
});
|
|
1083
|
+
if ("error" in alloc) {
|
|
1084
|
+
console.error(`Error: Failed to create tmux session: ${alloc.error}`);
|
|
221
1085
|
process.exit(1);
|
|
222
1086
|
}
|
|
1087
|
+
const sessionName = alloc.name;
|
|
1088
|
+
// gjc-parity session profile (mouse / clipboard / copy-mode style / @jeo-*
|
|
1089
|
+
// markers): wheel-up enters copy-mode over the REAL pane history, so the
|
|
1090
|
+
// mid-turn scrollback (ledger lines flushed above the live frame) is
|
|
1091
|
+
// reachable with the mouse wheel. Session-scoped (`-t =name`, never -g).
|
|
1092
|
+
// Best-effort per command: an old tmux missing an option is fine.
|
|
1093
|
+
for (const profileCmd of tmuxProfileCommands(sessionName, process.env, { branch: branch || undefined, project: cwd })) {
|
|
1094
|
+
try { Bun.spawnSync([tmuxBin, ...profileCmd.args]); } catch { /* best-effort */ }
|
|
1095
|
+
}
|
|
1096
|
+
console.log(
|
|
1097
|
+
sessionName === sessionBase
|
|
1098
|
+
? `Starting new tmux session: ${sessionName}`
|
|
1099
|
+
: `Starting new independent tmux session: ${sessionName} (another live jeo session already owns ${sessionBase}; reattach later with: tmux attach -t ${sessionName})`,
|
|
1100
|
+
);
|
|
223
1101
|
|
|
224
1102
|
const attach = Bun.spawn([tmuxBin, "attach-session", "-t", `=${sessionName}`], {
|
|
225
1103
|
stdin: "inherit",
|
|
@@ -231,54 +1109,161 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
231
1109
|
} else {
|
|
232
1110
|
console.warn("warning: tmux is not available on PATH. Launching directly...");
|
|
233
1111
|
}
|
|
1112
|
+
} else if (shouldEnableCurrentTmuxMouse(process.env)) {
|
|
1113
|
+
// `jeo --tmux` INSIDE an existing tmux session: no new session is created, but
|
|
1114
|
+
// wheel scrolling still needs tmux mouse mode — without it tmux ignores the
|
|
1115
|
+
// wheel entirely, so the live-turn scrollback contract (ledger lines flushed
|
|
1116
|
+
// above the inline frame) is unreachable ("scroll doesn't work"). Session-
|
|
1117
|
+
// scoped (never -g), best-effort; JEO_TMUX_MOUSE=0 opts out.
|
|
1118
|
+
const tmuxBin = Bun.which("tmux");
|
|
1119
|
+
if (tmuxBin) {
|
|
1120
|
+
try { Bun.spawnSync([tmuxBin, "set-option", "mouse", "on"]); } catch { /* best-effort */ }
|
|
1121
|
+
}
|
|
234
1122
|
}
|
|
235
1123
|
}
|
|
236
1124
|
|
|
237
|
-
const cfg = await readGlobalConfig();
|
|
238
|
-
const defaultModel = cfg.defaultModel;
|
|
239
1125
|
|
|
240
1126
|
// --list: print persisted sessions and exit.
|
|
241
1127
|
if (flags.list) {
|
|
242
1128
|
const sessions = await listSessions(cwd);
|
|
243
1129
|
if (sessions.length === 0) {
|
|
244
|
-
console.log("No saved sessions in .
|
|
1130
|
+
console.log("No saved sessions in .jeo/sessions/.");
|
|
245
1131
|
return;
|
|
246
1132
|
}
|
|
247
1133
|
console.log("Saved sessions (newest first):");
|
|
248
1134
|
for (const s of sessions) {
|
|
249
1135
|
console.log(` ${s.id} ${s.timestamp} (${s.messageCount} msgs) ${s.preview}`);
|
|
250
1136
|
}
|
|
251
|
-
console.log("\nResume with:
|
|
1137
|
+
console.log("\nResume with: jeo launch --resume <id>");
|
|
252
1138
|
return;
|
|
253
1139
|
}
|
|
254
1140
|
|
|
255
|
-
// pi-style: load project context (JEO.md / AGENTS.md / .
|
|
1141
|
+
// pi-style: load project context (JEO.md / AGENTS.md / .jeo/context.md / CLAUDE.md) into the prompt.
|
|
256
1142
|
const contextFiles = await loadProjectContext(cwd);
|
|
1143
|
+
|
|
1144
|
+
const KNOWN_TOOLS = new Set(["read", "write", "edit", "bash", "find", "search", "ls", "task", "todo"]);
|
|
1145
|
+
let allowedTools = new Set(KNOWN_TOOLS);
|
|
1146
|
+
|
|
1147
|
+
if (flags.noTools) {
|
|
1148
|
+
allowedTools = new Set();
|
|
1149
|
+
} else if (flags.tools) {
|
|
1150
|
+
const list = flags.tools.split(",").map(t => t.trim()).filter(Boolean);
|
|
1151
|
+
const valid: string[] = [];
|
|
1152
|
+
for (const name of list) {
|
|
1153
|
+
if (KNOWN_TOOLS.has(name)) {
|
|
1154
|
+
valid.push(name);
|
|
1155
|
+
} else {
|
|
1156
|
+
console.error(`Warning: Unknown tool name ignored: ${name}`);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
allowedTools = new Set(valid);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
let resolvedSkills: SkillDoc[] = [];
|
|
1163
|
+
if (!flags.noSkills) {
|
|
1164
|
+
const loaded = await loadSkills(cwd);
|
|
1165
|
+
if (flags.skills) {
|
|
1166
|
+
const patterns = flags.skills.split(",").map(p => p.trim()).filter(Boolean);
|
|
1167
|
+
resolvedSkills = loaded.filter(s => patterns.some(p => matchSkillGlob(p, s.name)));
|
|
1168
|
+
} else {
|
|
1169
|
+
resolvedSkills = loaded;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
const effectiveNoSkills = flags.noSkills || resolvedSkills.length === 0;
|
|
1174
|
+
|
|
1175
|
+
const workflowSkills = workflowSkillsForPrompt(resolvedSkills);
|
|
1176
|
+
const resolvedSkillNames = resolvedSkills.map(s => s.name);
|
|
1177
|
+
const skillSlashDetails: SlashCommandInfo[] = resolvedSkills.flatMap(skill =>
|
|
1178
|
+
skillSlashAliases(skill).map(alias => ({
|
|
1179
|
+
command: alias,
|
|
1180
|
+
usage: `${alias} [intent]`,
|
|
1181
|
+
description: `Run ${skill.name} skill${skill.summary ? ` — ${skill.summary}` : ""}`,
|
|
1182
|
+
group: "skills" as const,
|
|
1183
|
+
})),
|
|
1184
|
+
);
|
|
1185
|
+
|
|
1186
|
+
const protocol = buildToolProtocol(allowedTools);
|
|
1187
|
+
const preamble = flags.systemPrompt ?? "You are the jeo, an interactive coding agent.\nAccomplish the user's request by calling tools and verifying your work.";
|
|
1188
|
+
// Prior-session learnings (B6 경험 증류) — "" when absent or JEO_NO_MEMORY=1.
|
|
1189
|
+
const memoryBlock = await memoryPromptSection(cwd);
|
|
1190
|
+
|
|
257
1191
|
const baseSystemPrompt =
|
|
258
|
-
|
|
1192
|
+
preamble + "\n\n" + protocol + "\n\n" +
|
|
1193
|
+
WORKING_DISCIPLINE + "\n\n" +
|
|
1194
|
+
"Always verify (run tests / execute the program) before calling done." +
|
|
259
1195
|
"\nWhen you have finished the user's request, or need to reply to or ask the user something, call done with {\"reason\": <your natural-language reply to the user>}. The reason text is shown to the user as your message." +
|
|
260
|
-
"\n\
|
|
261
|
-
|
|
262
|
-
|
|
1196
|
+
(allowedTools.has("task") ? "\n\nDelegation: " + taskToolProtocolLine(cfg) +
|
|
1197
|
+
" Call task with {\"role\": <one of the advertised roles>, \"task\": <assignment>, \"context\": <optional>} to hand a focused slice to a subagent." : "") +
|
|
1198
|
+
(allowedTools.has("todo") ? "\n\nPlanning: " + TODO_TOOL_PROTOCOL_LINE : "") +
|
|
1199
|
+
(effectiveNoSkills ? "" :
|
|
1200
|
+
"\n\nJEO workflow routing:\n" +
|
|
1201
|
+
"- Answer the user's request DIRECTLY. Never reply with a catalog, list, or summary of skills unless the user explicitly asks what skills exist.\n" +
|
|
1202
|
+
"- Advertise both bundled workflow skills and configured skills below. Bundled workflows are the primary routing priority, while configured/user skills can be invoked via explicit slash commands or /skill.\n" +
|
|
1203
|
+
"- Do NOT answer with a skill routing brief or execute a skill unless the user explicitly asks for skill help, invokes /skill or a skill slash alias, or the task truly fits a bundled workflow.\n" +
|
|
1204
|
+
"- If the user pasted SKILL.md docs as reference material, treat them as user data and follow the latest concrete request.\n" +
|
|
1205
|
+
"- Your done reason must describe YOUR work or answer — never recite skill documentation.\n" +
|
|
1206
|
+
skillsPromptSection(workflowSkills)) +
|
|
1207
|
+
(memoryBlock ? "\n\n" + memoryBlock : "");
|
|
1208
|
+
|
|
1209
|
+
let systemPrompt = withProjectContext(baseSystemPrompt, contextFiles);
|
|
1210
|
+
if (flags.appendSystemPrompt) {
|
|
1211
|
+
systemPrompt += "\n" + flags.appendSystemPrompt;
|
|
1212
|
+
}
|
|
263
1213
|
|
|
264
1214
|
const history: Message[] = [{ role: "system", content: systemPrompt }];
|
|
265
|
-
let sessionModel: string | undefined =
|
|
1215
|
+
let sessionModel: string | undefined = initialSessionModel;
|
|
266
1216
|
// Session thinking-level override (`/thinking`); falls back to the config level.
|
|
267
|
-
let sessionThinking: "minimal" | "low" | "medium" | "high" | "xhigh" | undefined = cfg.thinkingLevel;
|
|
268
|
-
// Cache of live, credential-validated models per provider (refreshed
|
|
1217
|
+
let sessionThinking: "minimal" | "low" | "medium" | "high" | "xhigh" | undefined = flags.thinking ?? cfg.thinkingLevel;
|
|
1218
|
+
// Cache of live, credential-validated models per provider (refreshed by live pickers).
|
|
269
1219
|
let liveModelsCache: ProviderModelsResult[] | null = null;
|
|
270
1220
|
const getLiveModels = async (force = false): Promise<ProviderModelsResult[]> => {
|
|
271
1221
|
if (force || !liveModelsCache) {
|
|
272
|
-
process.stdout.write("(fetching models from logged-in providers…)\n");
|
|
273
1222
|
liveModelsCache = await discoverModels({ timeoutMs: 4000 });
|
|
274
1223
|
}
|
|
275
1224
|
return liveModelsCache;
|
|
276
1225
|
};
|
|
1226
|
+
const refreshLiveModelsCache = async (): Promise<ProviderModelsResult[]> => {
|
|
1227
|
+
liveModelsCache = null;
|
|
1228
|
+
return getLiveModels(true);
|
|
1229
|
+
};
|
|
277
1230
|
// The most recently displayed numbered pick list; `/model #N` selects from it.
|
|
278
1231
|
let lastPickIndex: PickEntry[] = [];
|
|
1232
|
+
// Cumulative provider token usage for this REPL process (`/usage`, gjc parity).
|
|
1233
|
+
const sessionUsage = { inputTokens: 0, outputTokens: 0, turns: 0 };
|
|
1234
|
+
// The last user request sent to the agent loop (`/retry`).
|
|
1235
|
+
let lastUserInput = "";
|
|
1236
|
+
// Full untruncated text of the last assistant reply — surfaced in detail by Ctrl+O.
|
|
1237
|
+
let lastReply = "";
|
|
1238
|
+
// Full untruncated output of the most recent tool call — the clipped forge
|
|
1239
|
+
// card's `⟦Ctrl+O for more⟧` hint resolves here.
|
|
1240
|
+
let lastToolDetail: { tool: string; output: string } | null = null;
|
|
1241
|
+
/** Wrap turn events so EVERY sink (TUI or plain stream) records the last full
|
|
1242
|
+
* tool output for the Ctrl+O detail view. */
|
|
1243
|
+
const withToolDetailCapture = (base: ReturnType<LaunchTui["events"]>): ReturnType<LaunchTui["events"]> => ({
|
|
1244
|
+
...base,
|
|
1245
|
+
onToolResult: (tool, success, output) => {
|
|
1246
|
+
lastToolDetail = { tool, output };
|
|
1247
|
+
base.onToolResult?.(tool, success, output);
|
|
1248
|
+
},
|
|
1249
|
+
});
|
|
1250
|
+
/** The Ctrl+O detail block (shared by the prompt-time keypress handler and the
|
|
1251
|
+
* mid-turn TUI binding): full last reply + full last tool output. */
|
|
1252
|
+
const composeDetailLines = (): string[] => {
|
|
1253
|
+
if (!lastReply && !lastToolDetail) return [];
|
|
1254
|
+
const sep = "─".repeat(Math.min(48, Math.max(20, (process.stdout.columns ?? 80) - 1)));
|
|
1255
|
+
const toolDetail = lastToolDetail
|
|
1256
|
+
? [sep, `detail · full last tool output (${lastToolDetail.tool})`, sep, ...lastToolDetail.output.split("\n").slice(0, 2000).map(sanitizeForTerminal)]
|
|
1257
|
+
: [];
|
|
1258
|
+
const replyDetail = lastReply
|
|
1259
|
+
? [sep, "detail · full last response (ctrl+o)", sep, ...renderMarkdownTables(lastReply).split("\n")]
|
|
1260
|
+
: [];
|
|
1261
|
+
return [...replyDetail, ...toolDetail, sep];
|
|
1262
|
+
};
|
|
279
1263
|
|
|
280
1264
|
// pi-style session persistence: resume an existing session or create a new one.
|
|
281
1265
|
let sessionId: string | undefined;
|
|
1266
|
+
let compactionSeq = 0;
|
|
282
1267
|
if (!flags.noSession) {
|
|
283
1268
|
if (flags.resume) {
|
|
284
1269
|
const id = flags.resumeId ?? (await latestSessionId(cwd));
|
|
@@ -301,59 +1286,206 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
301
1286
|
}
|
|
302
1287
|
}
|
|
303
1288
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
1289
|
+
// `step N/M` display seed: the explicit --max-steps cap, else the dynamic budget's
|
|
1290
|
+
// rolling base — the engine's onBudget event keeps the denominator honest as it grows.
|
|
1291
|
+
const initialStepLimit = flags.maxSteps > 0 ? flags.maxSteps : initialDynamicStepLimit();
|
|
1292
|
+
// Plain (non-TTY / --no-tui) progress sink — the cmd-mode equivalent of the live TUI.
|
|
1293
|
+
const streamEvents = createStreamEvents(initialStepLimit);
|
|
1294
|
+
let queueBusyInput: ((chunk: string) => boolean) | undefined;
|
|
1295
|
+
let queueBusyPasteActive: (() => boolean) | undefined;
|
|
1296
|
+
// Live snapshot of the busy-turn prompt draft — feeds the TUI's normal input
|
|
1297
|
+
// box during a running turn so typed text stays in the same query surface
|
|
1298
|
+
// instead of a separate queued row.
|
|
1299
|
+
let queueBusySnapshot: (() => { text: string }) | undefined;
|
|
1300
|
+
let interactiveTurnActive = false;
|
|
1301
|
+
|
|
308
1302
|
|
|
309
1303
|
// Run one conversational turn: compact, persist user msg, run the loop, persist + return the reply.
|
|
310
1304
|
// When `useTui`, a live TUI renders the turn and prints the final reply itself (rendered=true).
|
|
311
1305
|
const runTurn = async (
|
|
312
1306
|
userInput: string,
|
|
313
|
-
useTui: boolean
|
|
1307
|
+
useTui: boolean,
|
|
1308
|
+
images?: ImageAttachment[]
|
|
314
1309
|
): Promise<{ done: boolean; steps: number; reply: string; rendered: boolean; usage: string }> => {
|
|
315
|
-
|
|
1310
|
+
const turnConfig = await readGlobalConfig();
|
|
1311
|
+
const activeModel = sessionModel || turnConfig.defaultModel;
|
|
1312
|
+
const contextTokens = catalogMetadata(activeModel)?.contextTokens;
|
|
1313
|
+
|
|
1314
|
+
const compRes = await maybeCompact(history, {
|
|
1315
|
+
model: sessionModel,
|
|
1316
|
+
contextTokens,
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
if (compRes.error) {
|
|
1320
|
+
throw new Error(compRes.error);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
if (compRes.compacted && sessionId && compRes.replacesThrough !== undefined) {
|
|
1324
|
+
const touchedNote = compRes.touchedFiles?.length ? ` Files touched: ${compRes.touchedFiles.join(", ")}.` : "";
|
|
1325
|
+
const summaryText = compRes.summary ?? `[Earlier conversation omitted: ${compRes.removed} messages — summary unavailable.${touchedNote}]`;
|
|
1326
|
+
await appendCompaction(sessionId, ++compactionSeq, summaryText, compRes.replacesThrough, cwd);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
316
1329
|
const beforeLen = history.length;
|
|
317
|
-
|
|
1330
|
+
if (images?.length && catalogMetadata(activeModel)?.images === false) {
|
|
1331
|
+
console.log(`! ${activeModel} does not advertise image input — sending the attachment anyway.`);
|
|
1332
|
+
}
|
|
1333
|
+
history.push(images?.length ? { role: "user", content: userInput, images } : { role: "user", content: userInput });
|
|
318
1334
|
|
|
319
|
-
|
|
1335
|
+
// `turnConfig` was read before compaction so both the compactor and delegated
|
|
1336
|
+
// task tool see mid-session config changes (e.g. `/agents <role> <model>`).
|
|
320
1337
|
const { provider: activeProvider } = await describeModel(activeModel);
|
|
321
|
-
|
|
322
|
-
|
|
1338
|
+
// Dirty count is recomputed at each turn start (gjc parity P1.B5: per-turn, not
|
|
1339
|
+
// per-render) so `?N` grows as the agent edits files; one spawn/turn, not per frame.
|
|
1340
|
+
const turnDirtyCount = branch ? gitDirtyCount(cwd) : undefined;
|
|
1341
|
+
const tui = useTui ? new LaunchTui({ model: activeModel, provider: activeProvider, sessionId, maxSteps: initialStepLimit, cwd, branch, dirtyCount: turnDirtyCount, thinking: sessionThinking }) : null;
|
|
1342
|
+
tui?.setContextUsage(historyTokens(history), contextTokens);
|
|
1343
|
+
tui?.setTurnTitle(userInput); // gjc-parity turn title → HUD + tmux pane title (no LLM call)
|
|
323
1344
|
let result;
|
|
324
|
-
const ac = new AbortController();
|
|
325
|
-
const onSigint = () => ac.abort();
|
|
326
|
-
process.once("SIGINT", onSigint);
|
|
327
1345
|
try {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
1346
|
+
if (tui) {
|
|
1347
|
+
interactiveTurnActive = true;
|
|
1348
|
+
tui.start();
|
|
1349
|
+
}
|
|
1350
|
+
const harness = createInFlightAbortHarness({
|
|
1351
|
+
captureEsc: !!tui,
|
|
1352
|
+
onNoise: () => tui?.repaint(),
|
|
1353
|
+
pasteActive: () => queueBusyPasteActive?.() ?? false,
|
|
1354
|
+
// Ctrl+O mid-turn: flush the detail view into the TUI panel. Always
|
|
1355
|
+
// useful — even before the first reply/tool detail exists, the panel
|
|
1356
|
+
// shows the turn's timestamped recent-activity tail, so Ctrl+O answers
|
|
1357
|
+
// "what has been happening" instead of silently doing nothing.
|
|
1358
|
+
onDetailKey: () => {
|
|
1359
|
+
if (!tui) return;
|
|
1360
|
+
const lines = [...composeDetailLines()];
|
|
1361
|
+
const activity = tui.recentActivity(20);
|
|
1362
|
+
if (activity.length) {
|
|
1363
|
+
const sep = "─".repeat(40);
|
|
1364
|
+
lines.push(`activity · last ${activity.length} event(s) this turn`, sep, ...activity, sep);
|
|
1365
|
+
}
|
|
1366
|
+
if (lines.length === 0) return;
|
|
1367
|
+
tui.showDetail(lines);
|
|
1368
|
+
},
|
|
1369
|
+
onBufferedInput: chunk => {
|
|
1370
|
+
if (!tui) return;
|
|
1371
|
+
const captured = queueBusyInput?.(chunk) ?? false;
|
|
1372
|
+
// Keep the SAME query input box visible during a live turn. Printable
|
|
1373
|
+
// keystrokes edit the next prompt draft; Enter does not create a hidden
|
|
1374
|
+
// queue entry, so there is no separate "queued input" surface.
|
|
1375
|
+
if (captured) tui.setLivePromptInput(queueBusySnapshot?.().text ?? "");
|
|
1376
|
+
},
|
|
1377
|
+
onAbortNotice: msg => {
|
|
1378
|
+
if (tui) tui.events().onNotice?.(msg);
|
|
1379
|
+
else console.log(msg);
|
|
1380
|
+
},
|
|
1381
|
+
onHardExit: () => {
|
|
1382
|
+
if (tui) tui.finish("Cancelled.");
|
|
1383
|
+
process.exit(130);
|
|
1384
|
+
},
|
|
335
1385
|
});
|
|
1386
|
+
const ac = harness.controller;
|
|
1387
|
+
try {
|
|
1388
|
+
// Per-turn todo snapshot: drives the done-time reconciliation gate (the
|
|
1389
|
+
// Todos checklist used to end a finished turn stuck at "✓0 ◐1 ·4 / 5"
|
|
1390
|
+
// because nothing ever forced the model to update item statuses).
|
|
1391
|
+
let turnTodos: { title: string; status: string }[] = [];
|
|
1392
|
+
const onBeforeDone = (): string | null => {
|
|
1393
|
+
const unfinished = turnTodos.filter(t => t.status !== "done");
|
|
1394
|
+
if (turnTodos.length === 0 || unfinished.length === 0) return null;
|
|
1395
|
+
return (
|
|
1396
|
+
`Your todo list still shows ${unfinished.length} unfinished item(s): ${unfinished.map(t => `"${t.title}"`).join(", ")}. ` +
|
|
1397
|
+
`Reconcile the plan first — call the todo tool resending the FULL list with every actually-completed item marked "done" ` +
|
|
1398
|
+
`(drop items that no longer apply), then call done again.`
|
|
1399
|
+
);
|
|
1400
|
+
};
|
|
1401
|
+
const fullTools = {
|
|
1402
|
+
...DEFAULT_TOOLS,
|
|
1403
|
+
task: createTaskTool({
|
|
1404
|
+
config: { ...turnConfig, defaultModel: activeModel },
|
|
1405
|
+
signal: ac.signal,
|
|
1406
|
+
onEvent: useTui
|
|
1407
|
+
? (e => tui?.onSubagentEvent(e))
|
|
1408
|
+
: (e => logTaskSubEvent(e)),
|
|
1409
|
+
}),
|
|
1410
|
+
todo: createTodoTool({ onChange: items => { turnTodos = items; tui?.setTodos(items); } }),
|
|
1411
|
+
};
|
|
1412
|
+
const tools = filterToolMap(fullTools, Array.from(allowedTools));
|
|
1413
|
+
result = await runAgentLoop(history, {
|
|
1414
|
+
cwd,
|
|
1415
|
+
tools,
|
|
1416
|
+
maxSteps: flags.maxSteps,
|
|
1417
|
+
model: sessionModel,
|
|
1418
|
+
maxTokens: sessionThinking ? thinkingMaxTokens(sessionThinking) : undefined,
|
|
1419
|
+
signal: ac.signal,
|
|
1420
|
+
events: { ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone },
|
|
1421
|
+
});
|
|
1422
|
+
if (result.done && looksLikeSkillEcho(result.doneReason ?? "", resolvedSkills)) {
|
|
1423
|
+
history.push({
|
|
1424
|
+
role: "user",
|
|
1425
|
+
content:
|
|
1426
|
+
"Your previous reply was skill-document content, not an answer. Answer my actual request directly now — " +
|
|
1427
|
+
"use tools if needed, then call done with a concise reply in your own words. Do not quote skill docs.",
|
|
1428
|
+
});
|
|
1429
|
+
const retry = await runAgentLoop(history, {
|
|
1430
|
+
cwd,
|
|
1431
|
+
tools,
|
|
1432
|
+
maxSteps: Math.min(6, flags.maxSteps > 0 ? flags.maxSteps : 6),
|
|
1433
|
+
budget: { maxExtensions: 0 },
|
|
1434
|
+
model: sessionModel,
|
|
1435
|
+
maxTokens: sessionThinking ? thinkingMaxTokens(sessionThinking) : undefined,
|
|
1436
|
+
signal: ac.signal,
|
|
1437
|
+
events: withToolDetailCapture(tui ? tui.events() : streamEvents),
|
|
1438
|
+
});
|
|
1439
|
+
const usage =
|
|
1440
|
+
result.usage && retry.usage
|
|
1441
|
+
? {
|
|
1442
|
+
inputTokens: result.usage.inputTokens + retry.usage.inputTokens,
|
|
1443
|
+
outputTokens: result.usage.outputTokens + retry.usage.outputTokens,
|
|
1444
|
+
}
|
|
1445
|
+
: retry.usage ?? result.usage;
|
|
1446
|
+
result = { ...retry, steps: result.steps + retry.steps, usage };
|
|
1447
|
+
}
|
|
1448
|
+
} finally {
|
|
1449
|
+
harness.dispose();
|
|
1450
|
+
}
|
|
336
1451
|
} catch (err) {
|
|
337
|
-
if (tui)
|
|
1452
|
+
if (tui) {
|
|
1453
|
+
tui.finish(`! ${friendlyProviderError(err)}`);
|
|
1454
|
+
interactiveTurnActive = false;
|
|
1455
|
+
}
|
|
338
1456
|
throw err;
|
|
339
|
-
} finally {
|
|
340
|
-
process.removeListener("SIGINT", onSigint);
|
|
341
1457
|
}
|
|
342
|
-
|
|
1458
|
+
// A completed turn with an empty done-reason must NOT masquerade as a step-limit
|
|
1459
|
+
// failure ("reached the 3-step limit" after the model called done at step 3).
|
|
1460
|
+
const reply = result.doneReason
|
|
1461
|
+
|| (result.done
|
|
1462
|
+
? `(done in ${result.steps} step${result.steps === 1 ? "" : "s"} — the model returned no summary)`
|
|
1463
|
+
: `(reached the ${result.steps}-step limit without signaling done)`);
|
|
343
1464
|
// Full-fidelity persistence: append every message the engine added this turn
|
|
344
1465
|
// (user prompt + intermediate tool-call/tool-result turns), then the final reply.
|
|
345
|
-
|
|
346
|
-
|
|
1466
|
+
try {
|
|
1467
|
+
if (sessionId) {
|
|
1468
|
+
// One batched fs append for the whole turn (was: one awaited append per message).
|
|
1469
|
+
await appendMessages(sessionId, history.slice(beforeLen), cwd);
|
|
1470
|
+
}
|
|
1471
|
+
history.push({ role: "assistant", content: reply });
|
|
1472
|
+
if (sessionId) await appendMessage(sessionId, { role: "assistant", content: reply }, cwd);
|
|
1473
|
+
if (tui) tui.finish(reply);
|
|
1474
|
+
} finally {
|
|
1475
|
+
if (tui) interactiveTurnActive = false;
|
|
1476
|
+
}
|
|
1477
|
+
if (result.usage) {
|
|
1478
|
+
sessionUsage.inputTokens += result.usage.inputTokens;
|
|
1479
|
+
sessionUsage.outputTokens += result.usage.outputTokens;
|
|
347
1480
|
}
|
|
348
|
-
|
|
349
|
-
if (sessionId) await appendMessage(sessionId, { role: "assistant", content: reply }, cwd);
|
|
350
|
-
if (tui) tui.finish(reply);
|
|
1481
|
+
sessionUsage.turns++;
|
|
351
1482
|
const usage = result.usage ? ` (${result.usage.inputTokens} in / ${result.usage.outputTokens} out tokens)` : "";
|
|
352
1483
|
return { done: result.done, steps: result.steps, reply, rendered: !!tui, usage };
|
|
353
1484
|
};
|
|
354
1485
|
|
|
1486
|
+
|
|
355
1487
|
const joinedArgs = flags.message;
|
|
356
|
-
const isOneShot = joinedArgs.length > 0 || !process.stdin.isTTY;
|
|
1488
|
+
const isOneShot = flags.print || joinedArgs.length > 0 || !process.stdin.isTTY;
|
|
357
1489
|
|
|
358
1490
|
if (isOneShot) {
|
|
359
1491
|
let messageContent = joinedArgs;
|
|
@@ -364,73 +1496,1408 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
364
1496
|
console.log("No input provided.");
|
|
365
1497
|
return;
|
|
366
1498
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
1499
|
+
// One-shot piped/`-p` input that is a control slash command must be handled
|
|
1500
|
+
// HERE — never forwarded to the model. `echo "/clear" | jeo` (and Ctrl-D after
|
|
1501
|
+
// a piped command) previously sent the literal "/clear" to the LLM as a prompt:
|
|
1502
|
+
// a slow, flaky, and semantically wrong round-trip. These no-arg session/system
|
|
1503
|
+
// commands have a meaningful one-shot effect (or are simple no-ops) and exit.
|
|
1504
|
+
{
|
|
1505
|
+
const cmd = messageContent.trim();
|
|
1506
|
+
if (cmd === "/exit" || cmd === "/quit") {
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
if (cmd === "/clear" || cmd === "/new" || cmd === "/drop") {
|
|
1510
|
+
// Reset history to just the system prompt and overwrite the session file so
|
|
1511
|
+
// the persisted transcript matches (a fresh session for /new and /drop).
|
|
1512
|
+
history.length = 1;
|
|
1513
|
+
if (sessionId && !flags.noSession) {
|
|
1514
|
+
try {
|
|
1515
|
+
sessionId = (await createSession(cwd)).id;
|
|
1516
|
+
} catch { /* best-effort: in-memory clear already done */ }
|
|
1517
|
+
}
|
|
1518
|
+
console.log("(history cleared)");
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
if (cmd === "/" || cmd === "/?" || cmd === "/help") {
|
|
1522
|
+
for (const line of formatSlashCommandList("/", skillSlashDetails)) console.log(line);
|
|
1523
|
+
console.log("Tools: read / write / edit / bash / find / search. Sessions persist to .jeo/sessions/.");
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
const skillInvocation = parseSkillInvocation(messageContent, resolvedSkills);
|
|
1528
|
+
if (skillInvocation) {
|
|
1529
|
+
const isBundleWorkflow = ["deep-interview", "ralplan", "team", "ultragoal"].includes(skillInvocation.skill.name);
|
|
1530
|
+
if (isBundleWorkflow) {
|
|
1531
|
+
const startMsg: Message = {
|
|
1532
|
+
role: "system",
|
|
1533
|
+
content: `[workflow:${skillInvocation.skill.name}:start]${skillInvocation.intent ? ` intent: ${skillInvocation.intent}` : ""}`
|
|
1534
|
+
};
|
|
1535
|
+
history.push(startMsg);
|
|
1536
|
+
if (sessionId) {
|
|
1537
|
+
await appendMessage(sessionId, startMsg, cwd);
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
const harness = createInFlightAbortHarness({
|
|
1541
|
+
captureEsc: false,
|
|
1542
|
+
onAbortNotice: msg => console.log(msg),
|
|
1543
|
+
onHardExit: () => process.exit(130),
|
|
1544
|
+
});
|
|
1545
|
+
const ac = harness.controller;
|
|
1546
|
+
|
|
1547
|
+
const opts = {
|
|
1548
|
+
cwd,
|
|
1549
|
+
signal: ac.signal,
|
|
1550
|
+
onProgress: (e: { skill: string; phase: string; detail?: string }) => console.log(`[workflow:${e.skill}] ${e.phase}${e.detail ? ` — ${e.detail}` : ""}`),
|
|
1551
|
+
io: {
|
|
1552
|
+
output: (line: string) => {
|
|
1553
|
+
console.log(line);
|
|
1554
|
+
}
|
|
1555
|
+
},
|
|
1556
|
+
args: skillInvocation.skill.name === "deep-interview" ? (skillInvocation.intent ? skillInvocation.intent.split(/\s+/) : []) : undefined
|
|
1557
|
+
};
|
|
1558
|
+
|
|
1559
|
+
let ok = false;
|
|
1560
|
+
let reason: string | undefined;
|
|
1561
|
+
try {
|
|
1562
|
+
let res: { ok: boolean; reason?: string };
|
|
1563
|
+
if (skillInvocation.skill.name === "deep-interview") {
|
|
1564
|
+
res = await runDeepInterviewEngine(opts);
|
|
1565
|
+
} else if (skillInvocation.skill.name === "ralplan") {
|
|
1566
|
+
res = await runRalplanEngine(opts);
|
|
1567
|
+
} else if (skillInvocation.skill.name === "team") {
|
|
1568
|
+
res = await runTeamEngine(opts);
|
|
1569
|
+
} else {
|
|
1570
|
+
res = await runUltragoalEngine(opts);
|
|
1571
|
+
}
|
|
1572
|
+
ok = res.ok;
|
|
1573
|
+
reason = res.reason;
|
|
1574
|
+
} catch (err: any) {
|
|
1575
|
+
ok = false;
|
|
1576
|
+
reason = err.message;
|
|
1577
|
+
} finally {
|
|
1578
|
+
harness.dispose();
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
const endMsg: Message = {
|
|
1582
|
+
role: "system",
|
|
1583
|
+
content: ok
|
|
1584
|
+
? `[workflow:${skillInvocation.skill.name}:finish]`
|
|
1585
|
+
: `[workflow:${skillInvocation.skill.name}:abort]${reason ? ` reason: ${reason}` : ""}`
|
|
1586
|
+
};
|
|
1587
|
+
if (sessionId) {
|
|
1588
|
+
await appendMessage(sessionId, endMsg, cwd);
|
|
1589
|
+
}
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
const useOneShotTui = shouldUseOneShotTui(flags.noTui);
|
|
1594
|
+
if (!useOneShotTui) {
|
|
1595
|
+
console.log(`▶ Running skill: ${skillInvocation.skill.name}${skillInvocation.intent ? ` — ${skillInvocation.intent}` : ""}`);
|
|
1596
|
+
}
|
|
1597
|
+
const task = buildSkillTask(skillInvocation.skill, skillInvocation.intent, skillInvocation.invokedAs);
|
|
1598
|
+
const { reply, rendered, usage } = await runTurn(task, useOneShotTui);
|
|
1599
|
+
if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
|
|
1600
|
+
else if (usage) console.log(usage.trim());
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
try {
|
|
1604
|
+
const { reply, rendered, usage } = await runTurn(messageContent, shouldUseOneShotTui(flags.noTui));
|
|
1605
|
+
if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
|
|
1606
|
+
else if (usage) console.log(usage.trim());
|
|
1607
|
+
} catch (err) {
|
|
1608
|
+
console.log(`! ${friendlyProviderError(err)}`);
|
|
1609
|
+
}
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
// INTERACTIVE mode
|
|
1614
|
+
const updatePromise = checkForUpdate({ timeoutMs: 2500 });
|
|
1615
|
+
// Terminal hygiene BEFORE anything renders: a previous program (or stale tmux
|
|
1616
|
+
// pane) can leave xterm mouse-tracking ON, so the terminal reports clicks and
|
|
1617
|
+
// motion as escape sequences from the very first prompt — the "starts out
|
|
1618
|
+
// mouse-clicked" corruption. jeo never enables these modes; resetting them is
|
|
1619
|
+
// a no-op on a clean terminal.
|
|
1620
|
+
if (process.stdout.isTTY) process.stdout.write(resetMouseTracking());
|
|
1621
|
+
const activeStartModel = sessionModel || defaultModel;
|
|
1622
|
+
const { provider: startProvider } = await describeModel(activeStartModel);
|
|
1623
|
+
const welcomeTheme = resolveTheme(process.env);
|
|
1624
|
+
const welcomeData = {
|
|
1625
|
+
version: pkg.version,
|
|
1626
|
+
model: activeStartModel,
|
|
1627
|
+
provider: startProvider,
|
|
1628
|
+
cwd: cwd || process.cwd(),
|
|
1629
|
+
thinking: sessionThinking ?? "medium",
|
|
1630
|
+
sessionId,
|
|
1631
|
+
contextFiles: contextFiles.map(f => f.path),
|
|
1632
|
+
cols: terminalSize().cols,
|
|
1633
|
+
unicode: supportsUnicode(),
|
|
1634
|
+
color: welcomeTheme.color,
|
|
1635
|
+
accent: accentPaint(welcomeTheme),
|
|
1636
|
+
accentShadow: accentShadowPaint(welcomeTheme),
|
|
1637
|
+
};
|
|
1638
|
+
// Launch sweep: the DNA Claw's gradient loops seamlessly (default 2 full
|
|
1639
|
+
// cycles, JEO_WELCOME_ANIM_CYCLES overrides), ending on the static banner.
|
|
1640
|
+
// Truecolor TTYs only; JEO_NO_WELCOME_ANIM=1 opts out.
|
|
1641
|
+
const sweepable =
|
|
1642
|
+
!!process.stdout.isTTY &&
|
|
1643
|
+
welcomeTheme.color &&
|
|
1644
|
+
detectColorLevel(process.env, true) === ColorLevel.TrueColor &&
|
|
1645
|
+
jeoEnv("NO_WELCOME_ANIM") !== "1";
|
|
1646
|
+
const sweepCycles = Math.min(10, Math.max(1, Number(jeoEnv("WELCOME_ANIM_CYCLES")) || 2));
|
|
1647
|
+
if (sweepable) await playWelcomeSweep(welcomeData, { cycles: sweepCycles });
|
|
1648
|
+
else console.log(renderWelcome(welcomeData).join("\n"));
|
|
1649
|
+
|
|
1650
|
+
const upd = await Promise.race([updatePromise, new Promise<null>(r => setTimeout(() => r(null), 1200))]);
|
|
1651
|
+
if (upd?.updateAvailable) console.log(renderUpdateBox(upd.current, upd.latest).join("\n"));
|
|
1652
|
+
if (!LaunchTui.usable(flags.noTui)) console.log("(plain output)");
|
|
385
1653
|
|
|
386
1654
|
const useTui = LaunchTui.usable(flags.noTui);
|
|
1655
|
+
const runSkillInvocation = async (skill: SkillDoc, intent: string, invokedAs?: string): Promise<void> => {
|
|
1656
|
+
// gjc-style invocation card: surface WHAT is being injected before the work
|
|
1657
|
+
// starts — skill name, resolved SKILL.md path, and the prompt size.
|
|
1658
|
+
{
|
|
1659
|
+
const card = formatForgeBox(
|
|
1660
|
+
{ title: "[skill]", lines: skillInvocationCard(skill) },
|
|
1661
|
+
{ width: Math.min(100, Math.max(40, (process.stdout.columns ?? 80) - 2)), unicode: supportsUnicode(), paint: accentPaint(uiTheme), paintShadow: accentShadowPaint(uiTheme), color: uiTheme.color },
|
|
1662
|
+
);
|
|
1663
|
+
logLines(card);
|
|
1664
|
+
}
|
|
1665
|
+
const isBundleWorkflow = ["deep-interview", "ralplan", "team", "ultragoal"].includes(skill.name);
|
|
1666
|
+
if (isBundleWorkflow) {
|
|
1667
|
+
const startMsg: Message = {
|
|
1668
|
+
role: "system",
|
|
1669
|
+
content: `[workflow:${skill.name}:start]${intent ? ` intent: ${intent}` : ""}`
|
|
1670
|
+
};
|
|
1671
|
+
history.push(startMsg);
|
|
1672
|
+
if (sessionId) {
|
|
1673
|
+
await appendMessage(sessionId, startMsg, cwd);
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
const harness = createInFlightAbortHarness({
|
|
1677
|
+
captureEsc: false,
|
|
1678
|
+
onAbortNotice: msg => console.log(msg),
|
|
1679
|
+
onHardExit: () => process.exit(130),
|
|
1680
|
+
});
|
|
1681
|
+
const ac = harness.controller;
|
|
1682
|
+
|
|
1683
|
+
const opts = {
|
|
1684
|
+
cwd,
|
|
1685
|
+
signal: ac.signal,
|
|
1686
|
+
onProgress: (e: { skill: string; phase: string; detail?: string }) => console.log(`[workflow:${e.skill}] ${e.phase}${e.detail ? ` — ${e.detail}` : ""}`),
|
|
1687
|
+
io: {
|
|
1688
|
+
output: (line: string) => {
|
|
1689
|
+
console.log(line);
|
|
1690
|
+
},
|
|
1691
|
+
input: async () => {
|
|
1692
|
+
const wasPreviewArmed = previewArmed;
|
|
1693
|
+
if (wasPreviewArmed) {
|
|
1694
|
+
disarmPreview();
|
|
1695
|
+
previewArmed = false;
|
|
1696
|
+
}
|
|
1697
|
+
try {
|
|
1698
|
+
// EOF-safe prompt: a closed stdin yields "/exit" instead of a
|
|
1699
|
+
// never-settling question that would hang the workflow forever.
|
|
1700
|
+
return await promptInput("");
|
|
1701
|
+
} finally {
|
|
1702
|
+
if (wasPreviewArmed) {
|
|
1703
|
+
armPreview();
|
|
1704
|
+
drawFooter(previewLines(typedLine, navIdx));
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
},
|
|
1709
|
+
args: skill.name === "deep-interview" ? (intent ? intent.split(/\s+/) : []) : undefined
|
|
1710
|
+
};
|
|
1711
|
+
|
|
1712
|
+
let ok = false;
|
|
1713
|
+
let reason: string | undefined;
|
|
1714
|
+
try {
|
|
1715
|
+
let res: { ok: boolean; reason?: string };
|
|
1716
|
+
if (skill.name === "deep-interview") {
|
|
1717
|
+
res = await runDeepInterviewEngine(opts);
|
|
1718
|
+
} else if (skill.name === "ralplan") {
|
|
1719
|
+
res = await runRalplanEngine(opts);
|
|
1720
|
+
} else if (skill.name === "team") {
|
|
1721
|
+
res = await runTeamEngine(opts);
|
|
1722
|
+
} else {
|
|
1723
|
+
res = await runUltragoalEngine(opts);
|
|
1724
|
+
}
|
|
1725
|
+
ok = res.ok;
|
|
1726
|
+
reason = res.reason;
|
|
1727
|
+
} catch (err: any) {
|
|
1728
|
+
ok = false;
|
|
1729
|
+
reason = err.message;
|
|
1730
|
+
} finally {
|
|
1731
|
+
harness.dispose();
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
const endMsg: Message = {
|
|
1735
|
+
role: "system",
|
|
1736
|
+
content: ok
|
|
1737
|
+
? `[workflow:${skill.name}:finish]`
|
|
1738
|
+
: `[workflow:${skill.name}:abort]${reason ? ` reason: ${reason}` : ""}`
|
|
1739
|
+
};
|
|
1740
|
+
history.push(endMsg);
|
|
1741
|
+
if (sessionId) {
|
|
1742
|
+
await appendMessage(sessionId, endMsg, cwd);
|
|
1743
|
+
}
|
|
1744
|
+
} else {
|
|
1745
|
+
// Drive the agent loop to EXECUTE the skill (don't just dump the doc). A concise
|
|
1746
|
+
// banner replaces the old full-doc print; the live TUI shows progress, and the
|
|
1747
|
+
// final reply is the skill's result.
|
|
1748
|
+
if (!useTui) console.log(`▶ Running skill: ${skill.name}${intent ? ` — ${intent}` : ""}`);
|
|
1749
|
+
const task = buildSkillTask(skill, intent, invokedAs);
|
|
1750
|
+
const { reply, rendered, usage } = await runTurn(task, useTui);
|
|
1751
|
+
if (!rendered) console.log(`jeo> ${stripMarkdown(renderMarkdownTables(reply))}${usage}`);
|
|
1752
|
+
else if (usage) console.log(usage.trim());
|
|
1753
|
+
}
|
|
1754
|
+
};
|
|
1755
|
+
|
|
387
1756
|
// Tab autocomplete: alias names snapshotted once; live models come from the
|
|
388
1757
|
// background-warmed cache (logged-in/OAuth accounts). The completer is sync, so
|
|
389
1758
|
// it never blocks on the network — it reads whatever the cache currently holds.
|
|
390
1759
|
const aliasNames = Object.keys(await listAliases());
|
|
391
|
-
void
|
|
1760
|
+
void getLiveModels()
|
|
392
1761
|
.then(r => {
|
|
393
1762
|
liveModelsCache ??= r;
|
|
394
1763
|
})
|
|
395
1764
|
.catch(() => {});
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
1765
|
+
const mentionPaths = (prefix: string): string[] => {
|
|
1766
|
+
const norm = prefix.replace(/\\/g, "/");
|
|
1767
|
+
const wantsDirChildren = norm.endsWith("/");
|
|
1768
|
+
const dirPart = wantsDirChildren ? norm.slice(0, -1) : path.posix.dirname(norm) === "." ? "" : path.posix.dirname(norm);
|
|
1769
|
+
const namePart = wantsDirChildren ? "" : path.posix.basename(norm);
|
|
1770
|
+
const absDir = path.resolve(cwd, dirPart || ".");
|
|
1771
|
+
let entries: fs.Dirent[] = [];
|
|
1772
|
+
try {
|
|
1773
|
+
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
1774
|
+
} catch {
|
|
1775
|
+
return [];
|
|
1776
|
+
}
|
|
1777
|
+
return entries
|
|
1778
|
+
.filter(entry => !entry.name.startsWith("."))
|
|
1779
|
+
.filter(entry => !namePart || entry.name.toLowerCase().startsWith(namePart.toLowerCase()))
|
|
1780
|
+
.sort((a, b) => {
|
|
1781
|
+
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
|
|
1782
|
+
return a.name.localeCompare(b.name);
|
|
1783
|
+
})
|
|
1784
|
+
.slice(0, 50)
|
|
1785
|
+
.map(entry => {
|
|
1786
|
+
const rel = dirPart ? `${dirPart}/${entry.name}` : entry.name;
|
|
1787
|
+
return entry.isDirectory() ? `${rel}/` : rel;
|
|
1788
|
+
});
|
|
1789
|
+
};
|
|
1790
|
+
const completionContext = (): CompletionContext => {
|
|
1791
|
+
const base = staticCompletionContext();
|
|
1792
|
+
return {
|
|
1793
|
+
...base,
|
|
1794
|
+
slashCommands: [...base.slashCommands, ...skillSlashDetails.map(d => d.command)],
|
|
1795
|
+
liveModels: liveModelsCache ? flattenModels(liveModelsCache).map(e => e.model) : [],
|
|
1796
|
+
aliases: aliasNames,
|
|
1797
|
+
skillNames: resolvedSkillNames,
|
|
1798
|
+
modelsForProvider: p => liveModelsCache?.find(r => r.provider === p)?.models ?? [],
|
|
1799
|
+
mentionPaths,
|
|
1800
|
+
};
|
|
1801
|
+
};
|
|
1802
|
+
let previewArmed = false;
|
|
1803
|
+
let pickerActive = false;
|
|
402
1804
|
const rl = createInterface({
|
|
403
1805
|
input: process.stdin,
|
|
404
|
-
|
|
1806
|
+
// Single-box input: gate readline's output while the boxed footer is armed so its own
|
|
1807
|
+
// `jeo>` prompt/echo is suppressed and ONLY our box shows. (Bun exposes no
|
|
1808
|
+
// `_writeToOutput` to patch, so gating the shared output stream is the portable fix.)
|
|
1809
|
+
// The gate also covers active select pickers: they disarm the preview, which
|
|
1810
|
+
// previously OPENED the gate and let readline echo typed filter characters
|
|
1811
|
+
// (CJK wide chars especially) straight onto the picker frame — the
|
|
1812
|
+
// "stacked input-box borders" corruption.
|
|
1813
|
+
output: gatedStdout(process.stdout, () => previewArmed || pickerActive || interactiveTurnActive),
|
|
405
1814
|
completer: (line: string) => readlineCompleter(line, completionContext()),
|
|
406
1815
|
});
|
|
1816
|
+
const promptStdin = process.stdin as typeof process.stdin & { isRaw?: boolean; setRawMode?(raw: boolean): void };
|
|
1817
|
+
const promptWasRaw = !!promptStdin.isRaw;
|
|
1818
|
+
let promptRawChanged = false;
|
|
1819
|
+
const restorePromptRawMode = () => {
|
|
1820
|
+
if (!promptRawChanged) return;
|
|
1821
|
+
try { promptStdin.setRawMode?.(false); } catch { /* terminal gone */ }
|
|
1822
|
+
promptRawChanged = false;
|
|
1823
|
+
};
|
|
1824
|
+
if (promptStdin.isTTY && promptStdin.setRawMode && !promptWasRaw) {
|
|
1825
|
+
promptStdin.setRawMode(true);
|
|
1826
|
+
promptRawChanged = true;
|
|
1827
|
+
}
|
|
1828
|
+
process.once("exit", restorePromptRawMode);
|
|
1829
|
+
// Stdin EOF must END the REPL, not hang it: under Bun a pending `rl.question`
|
|
1830
|
+
// NEVER settles once the input stream closes (Ctrl-D, exhausted pipe) — the
|
|
1831
|
+
// while(true) prompt loop then waits forever (the "jeo never exits" hang).
|
|
1832
|
+
// Bun's readline also DROPS piped lines that arrive between prompts (question()
|
|
1833
|
+
// only captures the line submitted while it is registered; orphan lines emit
|
|
1834
|
+
// 'line' instead), so queue those and serve them before prompting again.
|
|
1835
|
+
const pendingStdinLines: string[] = [];
|
|
1836
|
+
const queuedPromptInput: PromptInputQueue = { pendingLines: pendingStdinLines, partial: "", pastedLines: [], inPaste: false };
|
|
1837
|
+
queueBusyInput = (chunk: string) => captureLivePromptInputChunk(queuedPromptInput, chunk);
|
|
1838
|
+
queueBusyPasteActive = () => queuedPromptInput.inPaste;
|
|
1839
|
+
queueBusySnapshot = () => ({
|
|
1840
|
+
text: queuedPromptInput.partial,
|
|
1841
|
+
});
|
|
1842
|
+
// Bracketed-paste line routing at the PROMPT: readline strips the 2004 markers
|
|
1843
|
+
// and replays pasted lines as synthetic keypresses, emitting paste-start /
|
|
1844
|
+
// paste-end around them. Lines submitted INSIDE that window are intentional
|
|
1845
|
+
// batch commands → pastedLines (one command per prompt, in order). Lines
|
|
1846
|
+
// outside it keep the existing contracts (typed-line prefill fold on a TTY,
|
|
1847
|
+
// in-order auto-serve for piped stdin).
|
|
1848
|
+
let promptPasteActive = false;
|
|
1849
|
+
if (process.stdin.isTTY) {
|
|
1850
|
+
process.stdin.on("keypress", (_ch: string, key: { name?: string } | undefined) => {
|
|
1851
|
+
if (key?.name === "paste-start") promptPasteActive = true;
|
|
1852
|
+
else if (key?.name === "paste-end") promptPasteActive = false;
|
|
1853
|
+
});
|
|
1854
|
+
// Enable bracketed paste for the REPL lifetime (restored on exit below):
|
|
1855
|
+
// terminals only wrap pastes in the 200~/201~ markers once the app opts in.
|
|
1856
|
+
process.stdout.write("\x1b[?2004h");
|
|
1857
|
+
process.once("exit", () => { try { process.stdout.write("\x1b[?2004l"); } catch { /* terminal gone */ } });
|
|
1858
|
+
}
|
|
1859
|
+
rl.on("line", l => {
|
|
1860
|
+
if (promptPasteActive) {
|
|
1861
|
+
queuedPromptInput.pastedLines.push(l);
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
if (!interactiveTurnActive) pendingStdinLines.push(l);
|
|
1865
|
+
});
|
|
1866
|
+
let stdinClosed = false;
|
|
1867
|
+
let notifyStdinClosed: (() => void) | undefined;
|
|
1868
|
+
let gracefulReadlineClose = false;
|
|
1869
|
+
let hardExitOnLoopEnd = false;
|
|
1870
|
+
// `on` + one-shot guard (not `once`): test harnesses stub readline with `on`/`question` only.
|
|
1871
|
+
rl.on("close", () => {
|
|
1872
|
+
if (stdinClosed) return;
|
|
1873
|
+
// Bun/readline can turn Ctrl+C into a bare close event without SIGINT/key
|
|
1874
|
+
// delivery. In an interactive terminal, an unexpected close is therefore a
|
|
1875
|
+
// hard break, not a graceful `/exit`. Some Bun/tmux paths leave stdin.isTTY
|
|
1876
|
+
// false while stdout is still a TTY, so accept either side as interactive.
|
|
1877
|
+
if ((process.stdin.isTTY || process.stdout.isTTY || previewEnabled) && !gracefulReadlineClose) {
|
|
1878
|
+
hardExitOnLoopEnd = true;
|
|
1879
|
+
forceExitFromCtrlC();
|
|
1880
|
+
}
|
|
1881
|
+
stdinClosed = true;
|
|
1882
|
+
notifyStdinClosed?.();
|
|
1883
|
+
});
|
|
1884
|
+
/** `rl.question` that resolves "/exit" on stdin EOF instead of hanging forever. */
|
|
1885
|
+
let promptServedFromPaste = false;
|
|
1886
|
+
const promptInput = async (prompt: string): Promise<string> => {
|
|
1887
|
+
promptServedFromPaste = false;
|
|
1888
|
+
// Pasted batch commands first — they execute one per prompt, in order.
|
|
1889
|
+
const pasted = queuedPromptInput.pastedLines.shift();
|
|
1890
|
+
if (pasted !== undefined) {
|
|
1891
|
+
promptServedFromPaste = true;
|
|
1892
|
+
return pasted;
|
|
1893
|
+
}
|
|
1894
|
+
const queued = pendingStdinLines.shift();
|
|
1895
|
+
if (queued !== undefined) return queued;
|
|
1896
|
+
if (stdinClosed) {
|
|
1897
|
+
if (hardExitOnLoopEnd || process.stdin.isTTY || process.stdout.isTTY) forceExitFromCtrlC();
|
|
1898
|
+
return "/exit";
|
|
1899
|
+
}
|
|
1900
|
+
try {
|
|
1901
|
+
return await Promise.race([
|
|
1902
|
+
rl.question(prompt),
|
|
1903
|
+
new Promise<string>(resolve => {
|
|
1904
|
+
notifyStdinClosed = () => {
|
|
1905
|
+
if (hardExitOnLoopEnd || process.stdin.isTTY || process.stdout.isTTY) forceExitFromCtrlC();
|
|
1906
|
+
resolve(pendingStdinLines.shift() ?? "/exit");
|
|
1907
|
+
};
|
|
1908
|
+
}),
|
|
1909
|
+
]);
|
|
1910
|
+
} finally {
|
|
1911
|
+
notifyStdinClosed = undefined;
|
|
1912
|
+
}
|
|
1913
|
+
};
|
|
407
1914
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
1915
|
+
// Mouse-wheel scroll during a live turn (tmux or plain terminal) can inject
|
|
1916
|
+
// arrow/scroll escape sequences into stdin; readline buffers them into its
|
|
1917
|
+
// pending line and the NEXT prompt then shows/executes garbage. Drain tty input
|
|
1918
|
+
// before each prompt, but preserve printable text typed while a live turn was
|
|
1919
|
+
// still running so fast follow-up prompts (including Korean/CJK text) are not
|
|
1920
|
+
// silently eaten.
|
|
1921
|
+
const drainPendingTtyInput = (): void => {
|
|
1922
|
+
if (!process.stdin.isTTY) return;
|
|
1923
|
+
try {
|
|
1924
|
+
let chunk: unknown;
|
|
1925
|
+
while ((chunk = process.stdin.read()) !== null) {
|
|
1926
|
+
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk as Uint8Array).toString("utf8");
|
|
1927
|
+
queuePromptInputChunk(queuedPromptInput, text);
|
|
1928
|
+
}
|
|
1929
|
+
} catch { /* stream not readable in this state — nothing buffered */ }
|
|
1930
|
+
const r = rl as unknown as { line?: string; cursor?: number };
|
|
1931
|
+
if (typeof r.line === "string" && r.line.length > 0 && /\x1b|\[[ABCD]/.test(r.line)) {
|
|
1932
|
+
r.line = "";
|
|
1933
|
+
r.cursor = 0;
|
|
1934
|
+
}
|
|
1935
|
+
};
|
|
1936
|
+
|
|
1937
|
+
// Live slash preview pinned to a reserved bottom footer via a DEC scroll region
|
|
1938
|
+
// (DECSTBM). The region is armed ONLY while waiting for input, and disarmed for
|
|
1939
|
+
// turns/command output so the full-screen turn TUI renders normally. The footer
|
|
1940
|
+
// is drawn at absolute rows (per-row clear → no scroll, no duplication).
|
|
1941
|
+
// Opt out with JEO_NO_SLASH_PREVIEW=1; auto-off on short terminals.
|
|
1942
|
+
const currentAtLabel = (line: string): string | undefined => {
|
|
1943
|
+
const { tokens } = tokenize(line);
|
|
1944
|
+
const token = [...tokens].reverse().find(t => t.startsWith("@"));
|
|
1945
|
+
if (!token) return undefined;
|
|
1946
|
+
const norm = token.slice(1).replace(/\\/g, "/");
|
|
1947
|
+
if (!norm) return "@ .";
|
|
1948
|
+
if (norm.endsWith("/")) return `@ ${norm.slice(0, -1) || "."}`;
|
|
1949
|
+
const dir = path.posix.dirname(norm);
|
|
1950
|
+
return `@ ${dir === "." ? norm : dir}`;
|
|
1951
|
+
};
|
|
1952
|
+
// Boxed-input footer height — ADAPTIVE so short terminals/panes still get the single
|
|
1953
|
+
// boxed input instead of silently falling back to the raw `jeo>` prompt (previously
|
|
1954
|
+
// any terminal under 17 rows lost the box entirely and showed bare CLI input).
|
|
1955
|
+
const MAX_PREVIEW_ROWS = 12;
|
|
1956
|
+
const MIN_PREVIEW_ROWS = 7; // status bar (1) + spacer (1) + input box (3 rows) + 2 preview rows
|
|
1957
|
+
const previewRowsFor = (rows: number): number => Math.max(MIN_PREVIEW_ROWS, Math.min(MAX_PREVIEW_ROWS, rows - 6));
|
|
1958
|
+
const previewEnabled =
|
|
1959
|
+
process.stdin.isTTY &&
|
|
1960
|
+
jeoEnv("NO_SLASH_PREVIEW") !== "1" &&
|
|
1961
|
+
(process.stdout.rows ?? 24) >= MIN_PREVIEW_ROWS + 6; // box + ≥6 scrollable content rows
|
|
1962
|
+
// Footer height reserved by the CURRENTLY armed region; disarm/draw must use the
|
|
1963
|
+
// same value the arm computed, even if the terminal was resized in between.
|
|
1964
|
+
let footerRows = MAX_PREVIEW_ROWS;
|
|
1965
|
+
const out = process.stdout;
|
|
1966
|
+
// Arrow-key selection over the slash preview list.
|
|
1967
|
+
let navMatches: string[] = []; // command names matching the typed keyword (display order)
|
|
1968
|
+
let navIdx = -1; // highlighted row, -1 = none
|
|
1969
|
+
let typedLine = ""; // the user-typed line (restored after readline's history nav)
|
|
1970
|
+
let pendingSelection: string | undefined; // command chosen via arrows, applied on Enter
|
|
1971
|
+
let pendingImages: ImageAttachment[] = []; // clipboard images attached to the next message (ctrl+v)
|
|
1972
|
+
let pasteInFlight = false; // guard concurrent ctrl+v clipboard reads
|
|
1973
|
+
let idleDirtyCount: number | undefined; // git dirty count refreshed once per prompt
|
|
1974
|
+
let lastFooterKey = "";
|
|
1975
|
+
const logLines = (lines: string | string[]) => {
|
|
1976
|
+
const arr = Array.isArray(lines) ? lines : [lines];
|
|
1977
|
+
const cols = Math.max(20, (process.stdout.columns ?? 80) - 1);
|
|
1978
|
+
for (const line of arr) {
|
|
1979
|
+
console.log(truncateAnsi(line, cols));
|
|
1980
|
+
}
|
|
1981
|
+
};
|
|
1982
|
+
let previewPending = false;
|
|
1983
|
+
let promptHistoryLines: string[] | null = null;
|
|
1984
|
+
|
|
1985
|
+
// Inline boxed-footer rendering with a FIXED reservation (the "@-mention typing
|
|
1986
|
+
// pushes the box down" fix). The footer reserves its full `footerRows` height
|
|
1987
|
+
// eagerly on arm (one-time scroll cost), and every redraw paints inside that
|
|
1988
|
+
// reservation with CUD (cursor-down) only — never `\n`. The old grow path emitted
|
|
1989
|
+
// `\n` whenever lines.length > footerRendered, and `\n` at the bottom margin
|
|
1990
|
+
// SCROLLS the terminal: every keystroke that wrapped the input box body or grew
|
|
1991
|
+
// the `Paths:` preview ate a row of prior output and misaligned the next repaint.
|
|
1992
|
+
// With a fixed reservation, footer height is constant for the lifetime of the
|
|
1993
|
+
// prompt, so the box can never grow, scroll, or break alignment.
|
|
1994
|
+
let footerRendered = 0; // rows of the reserved region (= footerRows once armed)
|
|
1995
|
+
// Caret cell of the boxed input (row relative to the reservation top, 1-based col),
|
|
1996
|
+
// recomputed by previewLines from readline's live rl.cursor. drawFooter parks the
|
|
1997
|
+
// REAL terminal cursor there, so the blinking caret sits right after the `>` prompt
|
|
1998
|
+
// and visibly follows arrow-key movement.
|
|
1999
|
+
let footerCursor = { row: 0, col: 1 };
|
|
2000
|
+
// Row (within the reservation) where the real cursor was last parked; the next
|
|
2001
|
+
// drawFooter/disarmPreview must hop back to the top from here before painting.
|
|
2002
|
+
let footerParkedRow = 0;
|
|
2003
|
+
const padToFooter = (lines: string[]): string[] => {
|
|
2004
|
+
if (lines.length >= footerRows) return lines.slice(0, footerRows);
|
|
2005
|
+
return [...lines, ...new Array(footerRows - lines.length).fill("")];
|
|
2006
|
+
};
|
|
2007
|
+
const armPreview = () => {
|
|
2008
|
+
if (!previewEnabled || previewArmed) return;
|
|
2009
|
+
footerRows = previewRowsFor(process.stdout.rows ?? 24);
|
|
2010
|
+
// Reserve `footerRows` bottom rows: write blank newlines (the terminal scrolls
|
|
2011
|
+
// ONCE here, not on every keystroke), then park the cursor at the top of the
|
|
2012
|
+
// reservation. Every subsequent drawFooter call stays inside this region.
|
|
2013
|
+
if (footerRows > 1) {
|
|
2014
|
+
out.write("\n".repeat(footerRows - 1) + cursorUp(footerRows - 1));
|
|
2015
|
+
}
|
|
2016
|
+
out.write(toColumn(1));
|
|
2017
|
+
footerRendered = footerRows;
|
|
2018
|
+
footerParkedRow = 0;
|
|
2019
|
+
previewArmed = true;
|
|
2020
|
+
lastFooterKey = "";
|
|
2021
|
+
};
|
|
2022
|
+
// Clear the reserved region and park the cursor at its top row so subsequent
|
|
2023
|
+
// command output starts where the box was (and inherits the existing scrollback).
|
|
2024
|
+
const disarmPreview = () => {
|
|
2025
|
+
if (!previewArmed) return;
|
|
2026
|
+
previewArmed = false;
|
|
2027
|
+
lastFooterKey = "";
|
|
2028
|
+
if (footerRendered > 0) {
|
|
2029
|
+
// Hop back to the reservation top from wherever the caret was parked.
|
|
2030
|
+
let s = footerParkedRow > 0 ? cursorUp(footerParkedRow) : "";
|
|
2031
|
+
footerParkedRow = 0;
|
|
2032
|
+
for (let i = 0; i < footerRendered; i++) {
|
|
2033
|
+
s += toColumn(1) + clearLine();
|
|
2034
|
+
if (i < footerRendered - 1) s += "\x1b[1B"; // CUD: no scroll at bottom margin
|
|
2035
|
+
}
|
|
2036
|
+
if (footerRendered > 1) s += cursorUp(footerRendered - 1);
|
|
2037
|
+
s += toColumn(1) + "\x1b[?25h";
|
|
2038
|
+
out.write(s);
|
|
2039
|
+
footerRendered = 0;
|
|
2040
|
+
} else {
|
|
2041
|
+
out.write("\x1b[?25h");
|
|
2042
|
+
}
|
|
2043
|
+
};
|
|
2044
|
+
// KEYSTROKE-HOT theme handle: `previewLines`/`statusBarLine` run on EVERY
|
|
2045
|
+
// keypress, and an uncached `resolveTheme` walks config-file I/O and (on
|
|
2046
|
+
// macOS without a configured theme) an execSync `defaults read` appearance
|
|
2047
|
+
// probe ≈ 12ms/call — ×3 calls/key was the visible typing delay. Resolve
|
|
2048
|
+
// once, refresh ONLY when `/theme` changes the env.
|
|
2049
|
+
let uiTheme = resolveTheme(process.env);
|
|
2050
|
+
let uiAccent = accentPaint(uiTheme);
|
|
2051
|
+
let uiAccentShadow = accentShadowPaint(uiTheme);
|
|
2052
|
+
const refreshUiTheme = (): void => {
|
|
2053
|
+
uiTheme = resolveTheme(process.env);
|
|
2054
|
+
uiAccent = accentPaint(uiTheme);
|
|
2055
|
+
uiAccentShadow = accentShadowPaint(uiTheme);
|
|
2056
|
+
};
|
|
2057
|
+
// The gjc-layout status bar pinned directly ABOVE the input box: bg-gradient
|
|
2058
|
+
// identity block (model · thinking / branch / cwd) left, live ctx% right.
|
|
2059
|
+
const statusBarLine = (cols: number): string => {
|
|
2060
|
+
const activeModel = sessionModel || defaultModel;
|
|
2061
|
+
const meta = catalogMetadata(activeModel);
|
|
2062
|
+
const used = historyTokens(history);
|
|
2063
|
+
const theme = uiTheme;
|
|
2064
|
+
return renderStatusBar({
|
|
2065
|
+
model: activeModel,
|
|
2066
|
+
thinking: sessionThinking,
|
|
2067
|
+
branch,
|
|
2068
|
+
dirtyCount: idleDirtyCount,
|
|
2069
|
+
cwd,
|
|
2070
|
+
ctxPct: meta?.contextTokens ? (used / meta.contextTokens) * 100 : undefined,
|
|
2071
|
+
ctxMaxTokens: meta?.contextTokens,
|
|
2072
|
+
cols,
|
|
2073
|
+
unicode: true,
|
|
2074
|
+
color: theme.color,
|
|
2075
|
+
colorLevel: detectColorLevel(process.env, true),
|
|
2076
|
+
gradient: themeGradient(theme, 2),
|
|
2077
|
+
});
|
|
2078
|
+
};
|
|
2079
|
+
const previewLines = (line: string, selected = -1): string[] => {
|
|
2080
|
+
const cols = Math.max(24, (process.stdout.columns ?? 80) - 1);
|
|
2081
|
+
// Caret offset comes from readline's live cursor when it matches the rendered
|
|
2082
|
+
// line (arrow keys/Home/End move it); otherwise (history nav mismatch) caret
|
|
2083
|
+
// sits at the end of the text.
|
|
2084
|
+
const rli = rl as unknown as { line?: string; cursor?: number };
|
|
2085
|
+
const caret = rli.line === line && typeof rli.cursor === "number" ? rli.cursor : line.length;
|
|
2086
|
+
const frame = renderInputFrame(line, {
|
|
2087
|
+
cols,
|
|
2088
|
+
color: true,
|
|
2089
|
+
unicode: true,
|
|
2090
|
+
accent: uiAccent,
|
|
2091
|
+
accentShadow: uiAccentShadow,
|
|
2092
|
+
cwdLabel: currentAtLabel(line),
|
|
2093
|
+
attachmentLabel: pendingImages.length
|
|
2094
|
+
? `⧉ ${pendingImages.length} image${pendingImages.length > 1 ? "s" : ""} attached — sent with the next message`
|
|
2095
|
+
: undefined,
|
|
2096
|
+
maxBodyRows: Math.max(1, footerRows - 7),
|
|
2097
|
+
cursor: caret,
|
|
2098
|
+
});
|
|
2099
|
+
const input = frame.lines.map(l => truncateAnsi(l, cols));
|
|
2100
|
+
// jeo-ref layout: a blank spacer row between the status bar (row 0) and the
|
|
2101
|
+
// input box, so the box breathes instead of gluing to the bar — the caret
|
|
2102
|
+
// (and everything below) therefore shifts down TWO rows.
|
|
2103
|
+
footerCursor = {
|
|
2104
|
+
row: Math.max(0, Math.min(frame.cursorRow + 2, footerRows - 1)),
|
|
2105
|
+
col: Math.max(1, Math.min(frame.cursorCol, cols)),
|
|
2106
|
+
};
|
|
2107
|
+
const budget = Math.max(0, footerRows - 2 - input.length);
|
|
2108
|
+
const slash = budget > 0 ? formatSlashPreview(line, budget, selected, skillSlashDetails, resolvedSkills) : [];
|
|
2109
|
+
const args = !slash.length && budget > 0 ? formatCompletionPreview(line, completionContext(), budget) : [];
|
|
2110
|
+
const preview = (slash.length ? slash : args).map(l => chalk.gray(truncateAnsi(l, cols)));
|
|
2111
|
+
return [statusBarLine(cols), "", ...input, ...preview].slice(0, footerRows);
|
|
2112
|
+
};
|
|
2113
|
+
const historyPreviewLines = (detail: string[]): string[] => {
|
|
2114
|
+
const cols = Math.max(24, (process.stdout.columns ?? 80) - 1);
|
|
2115
|
+
const title = `${chalk.cyan.bold("history")} ${chalk.gray("· Ctrl+O closes")}`;
|
|
2116
|
+
const budget = Math.max(0, footerRows - 2);
|
|
2117
|
+
const physical = detail.flatMap(line => line.split("\n")).map(line => truncateAnsi(line, cols));
|
|
2118
|
+
let body = physical;
|
|
2119
|
+
if (physical.length > budget) {
|
|
2120
|
+
const keep = Math.max(0, budget - 1);
|
|
2121
|
+
body = physical.slice(0, keep);
|
|
2122
|
+
body.push(chalk.gray(`… ${physical.length - keep} more line(s)`));
|
|
2123
|
+
} else {
|
|
2124
|
+
body = physical.slice(0, budget);
|
|
2125
|
+
}
|
|
2126
|
+
footerCursor = { row: Math.min(1, footerRows - 1), col: 1 };
|
|
2127
|
+
return [statusBarLine(cols), title, ...body].slice(0, footerRows);
|
|
2128
|
+
};
|
|
2129
|
+
const drawFooter = (lines: string[]) => {
|
|
2130
|
+
if (!previewArmed || footerRendered === 0) return;
|
|
2131
|
+
// ALWAYS paint exactly footerRendered rows so the reservation is fully covered
|
|
2132
|
+
// and no row can spill past it — the bug fix that kept `@folder<more text>`
|
|
2133
|
+
// typing from scrolling the input box (and prior output) off the top.
|
|
2134
|
+
const padded = padToFooter(lines);
|
|
2135
|
+
// Pure caret moves (arrow keys) change no content — include the caret cell in
|
|
2136
|
+
// the repaint key so they still reposition the terminal cursor.
|
|
2137
|
+
const tRow = lines.length ? Math.min(footerCursor.row, footerRendered - 1) : 0;
|
|
2138
|
+
const tCol = lines.length ? footerCursor.col : 1;
|
|
2139
|
+
const key = `${padded.join("\n")}\u0000${tRow}:${tCol}`;
|
|
2140
|
+
if (key === lastFooterKey) return;
|
|
2141
|
+
lastFooterKey = key;
|
|
2142
|
+
// Hop back to the reservation top from the previously parked caret row, then
|
|
2143
|
+
// paint top→bottom using CUD only.
|
|
2144
|
+
let s = footerParkedRow > 0 ? cursorUp(footerParkedRow) : "";
|
|
2145
|
+
s += toColumn(1);
|
|
2146
|
+
for (let i = 0; i < footerRendered; i++) {
|
|
2147
|
+
s += toColumn(1) + clearLine();
|
|
2148
|
+
if (padded[i]) s += padded[i];
|
|
2149
|
+
if (i < footerRendered - 1) s += "\x1b[1B"; // CUD: never scroll
|
|
2150
|
+
}
|
|
2151
|
+
if (footerRendered > 1) s += cursorUp(footerRendered - 1);
|
|
2152
|
+
// Park the REAL cursor at the caret cell (right after the `>` prompt) so the
|
|
2153
|
+
// blinking terminal cursor marks the insertion point and follows arrow keys.
|
|
2154
|
+
s += toColumn(1);
|
|
2155
|
+
if (tRow > 0) s += `\x1b[${tRow}B`;
|
|
2156
|
+
s += toColumn(tCol) + "\x1b[?25h";
|
|
2157
|
+
footerParkedRow = tRow;
|
|
2158
|
+
out.write(s);
|
|
2159
|
+
};
|
|
2160
|
+
|
|
2161
|
+
// ESC at the prompt: wipe the typed text (and detach any pending clipboard
|
|
2162
|
+
// images — their `[image #N]` tags live in that text) instead of leaving stale
|
|
2163
|
+
// input. Ctrl+C is no longer a line editor shortcut; it hard-exits below.
|
|
2164
|
+
const clearTypedInput = (): boolean => {
|
|
2165
|
+
const rli = rl as unknown as { line: string; cursor: number; _refreshLine?: () => void };
|
|
2166
|
+
const hadPastedQueue = queuedPromptInput.pastedLines.length > 0;
|
|
2167
|
+
if ((rli.line?.length ?? 0) === 0 && pendingImages.length === 0 && !hadPastedQueue) return false;
|
|
2168
|
+
// ESC is the escape hatch for an accidental giant paste: drop the queued batch.
|
|
2169
|
+
if (hadPastedQueue) {
|
|
2170
|
+
const dropped = queuedPromptInput.pastedLines.splice(0).length;
|
|
2171
|
+
console.log(chalk.dim(`(discarded ${dropped} queued pasted command${dropped > 1 ? "s" : ""})`));
|
|
2172
|
+
}
|
|
2173
|
+
rli.line = "";
|
|
2174
|
+
rli.cursor = 0;
|
|
2175
|
+
rli._refreshLine?.();
|
|
2176
|
+
pendingImages = [];
|
|
2177
|
+
typedLine = "";
|
|
2178
|
+
navMatches = [];
|
|
2179
|
+
navIdx = -1;
|
|
2180
|
+
pendingSelection = undefined;
|
|
2181
|
+
if (previewArmed) drawFooter(previewLines(""));
|
|
2182
|
+
return true;
|
|
2183
|
+
};
|
|
2184
|
+
// Ctrl+C at the prompt is a hard terminal break (exit code 130), not a line
|
|
2185
|
+
// editor shortcut. `/exit` remains the graceful session-save path; ^C is the
|
|
2186
|
+
// emergency "get me back to my shell now" path and must work on the first press.
|
|
2187
|
+
const forceExitFromCtrlC = () => {
|
|
2188
|
+
try {
|
|
2189
|
+
disarmPreview();
|
|
2190
|
+
out.write("\x1b[?25h\n");
|
|
2191
|
+
restorePromptRawMode();
|
|
2192
|
+
} catch {
|
|
2193
|
+
// Best-effort terminal restore; process exit is the contract.
|
|
2194
|
+
}
|
|
2195
|
+
process.exit(130);
|
|
2196
|
+
};
|
|
2197
|
+
const forceExitOnCtrlCByte = (chunk: string | Uint8Array) => {
|
|
2198
|
+
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
2199
|
+
if (text.includes("\u0003")) forceExitFromCtrlC();
|
|
2200
|
+
};
|
|
2201
|
+
// Bun/readline can deliver Ctrl+C as readline SIGINT, process SIGINT, or (tmux)
|
|
2202
|
+
// a raw \u0003 byte before readline resolves the question; wire all three.
|
|
2203
|
+
process.on("SIGINT", forceExitFromCtrlC);
|
|
2204
|
+
process.stdin.on("data", forceExitOnCtrlCByte);
|
|
2205
|
+
rl.on("SIGINT", forceExitFromCtrlC);
|
|
2206
|
+
|
|
2207
|
+
const runSelectPicker = async <T>(
|
|
2208
|
+
render: (cols: number, rows: number) => string[],
|
|
2209
|
+
onKey: (ch: string, key: { name?: string; ctrl?: boolean; meta?: boolean } | undefined) => boolean | undefined,
|
|
2210
|
+
): Promise<void> => {
|
|
2211
|
+
pickerActive = true; // closes the readline output gate for the picker's lifetime
|
|
2212
|
+
disarmPreview();
|
|
2213
|
+
// NOTE: deliberately NOT rl.pause() — pausing stops the underlying stdin stream,
|
|
2214
|
+
// which would also starve the picker's own "keypress" listener (picker hang).
|
|
2215
|
+
// Echo suppression is handled by the output gate above; raw mode (kept below)
|
|
2216
|
+
// prevents terminal-driver local echo.
|
|
2217
|
+
const wasRaw = process.stdin.isRaw;
|
|
2218
|
+
if (process.stdin.setRawMode && !wasRaw) {
|
|
2219
|
+
process.stdin.setRawMode(true);
|
|
2220
|
+
}
|
|
2221
|
+
process.stdin.resume();
|
|
2222
|
+
const cols = Math.max(40, terminalSize().cols - 2);
|
|
2223
|
+
const rows = Math.max(6, terminalSize().rows - 6);
|
|
2224
|
+
let rendered = 0;
|
|
2225
|
+
const repaint = () => {
|
|
2226
|
+
const lines = render(cols, rows).map(line => truncateAnsi(line, cols));
|
|
2227
|
+
const total = Math.max(rendered, lines.length);
|
|
2228
|
+
// First paint: drop down ONCE to start on a fresh row; subsequent paints:
|
|
2229
|
+
// cursor sits on the LAST rendered row, so cursorUp(rendered - 1) returns
|
|
2230
|
+
// to row 0 of the prior block (off-by-one fix — the old `cursorUp(rendered)`
|
|
2231
|
+
// landed ABOVE the block, so each repaint duplicated the trailing hint
|
|
2232
|
+
// line below the picker instead of overwriting it).
|
|
2233
|
+
let s = rendered > 0 ? (rendered > 1 ? cursorUp(rendered - 1) : "") + toColumn(1) : "\n";
|
|
2234
|
+
for (let i = 0; i < total; i++) {
|
|
2235
|
+
s += toColumn(1) + clearLine();
|
|
2236
|
+
if (i < lines.length) s += lines[i]!;
|
|
2237
|
+
// Rows the block already occupies move with CUD (no scroll at the bottom
|
|
2238
|
+
// margin → no anchor drift); only genuinely NEW rows use a real newline.
|
|
2239
|
+
if (i < total - 1) s += i < rendered - 1 ? "\x1b[1B" + toColumn(1) : "\n";
|
|
2240
|
+
}
|
|
2241
|
+
out.write(s + "\x1b[?25h");
|
|
2242
|
+
rendered = total;
|
|
2243
|
+
};
|
|
2244
|
+
const clear = () => {
|
|
2245
|
+
if (rendered <= 0) return;
|
|
2246
|
+
// Same off-by-one: from last row, cursorUp(rendered - 1) reaches row 0.
|
|
2247
|
+
let s = (rendered > 1 ? cursorUp(rendered - 1) : "") + toColumn(1);
|
|
2248
|
+
for (let i = 0; i < rendered; i++) {
|
|
2249
|
+
s += toColumn(1) + clearLine();
|
|
2250
|
+
// Every row here already exists — CUD only, never a scrolling newline.
|
|
2251
|
+
if (i < rendered - 1) s += "\x1b[1B" + toColumn(1);
|
|
2252
|
+
}
|
|
2253
|
+
// Park back at the first cleared row so post-picker output starts there.
|
|
2254
|
+
if (rendered > 1) s += cursorUp(rendered - 1);
|
|
2255
|
+
s += toColumn(1);
|
|
2256
|
+
out.write(s + "\x1b[?25h");
|
|
2257
|
+
rendered = 0;
|
|
2258
|
+
};
|
|
2259
|
+
repaint();
|
|
2260
|
+
try {
|
|
2261
|
+
await new Promise<void>(resolve => {
|
|
2262
|
+
const handler = (ch: string, key: { name?: string; ctrl?: boolean; meta?: boolean } | undefined) => {
|
|
2263
|
+
const done = onKey(ch, key);
|
|
2264
|
+
if (done) {
|
|
2265
|
+
process.stdin.off("keypress", handler);
|
|
2266
|
+
clear();
|
|
2267
|
+
resolve();
|
|
2268
|
+
return;
|
|
2269
|
+
}
|
|
2270
|
+
repaint();
|
|
2271
|
+
};
|
|
2272
|
+
process.stdin.on("keypress", handler);
|
|
2273
|
+
});
|
|
2274
|
+
} finally {
|
|
2275
|
+
if (process.stdin.setRawMode && !wasRaw) {
|
|
2276
|
+
process.stdin.setRawMode(false);
|
|
2277
|
+
}
|
|
2278
|
+
// Keys typed while the picker was open also landed in readline's hidden
|
|
2279
|
+
// line buffer; without this the NEXT prompt starts pre-filled with the
|
|
2280
|
+
// picker's filter text (invisible until submitted as garbage input).
|
|
2281
|
+
const rli = rl as unknown as { line?: string; cursor?: number };
|
|
2282
|
+
if (typeof rli.line === "string" && rli.line.length > 0) {
|
|
2283
|
+
rli.line = "";
|
|
2284
|
+
rli.cursor = 0;
|
|
2285
|
+
}
|
|
2286
|
+
pickerActive = false;
|
|
2287
|
+
}
|
|
2288
|
+
};
|
|
2289
|
+
|
|
2290
|
+
// Antigravity with ANY Google OAuth (own login or the gemini-cli fallback) stays
|
|
2291
|
+
// SELECTABLE in pickers even when not call-ready: picking the model is how users
|
|
2292
|
+
// reach the flow, and the auth layer gives actionable login guidance on the first
|
|
2293
|
+
// call if the fallback token is rejected (403). Refusing selection was a dead end.
|
|
2294
|
+
const selectableThoughNotReady = (st?: { name: string; kind: string }): boolean =>
|
|
2295
|
+
!!st && st.name === "antigravity" && st.kind === "oauth";
|
|
2296
|
+
const notReadyWarning = (st: { name: string; label: string }): string =>
|
|
2297
|
+
` ! ${st.name} is not call-ready yet (${st.label}) — run /provider login antigravity before the first turn.`;
|
|
2298
|
+
|
|
2299
|
+
const CORE_MODEL_ACTION_ROLE_ORDER = ["executor", "architect", "planner", "critic"] as const;
|
|
2300
|
+
const MODEL_BADGE_ROLE_ORDER = ["planner", "architect", "executor", "critic"] as const;
|
|
2301
|
+
|
|
2302
|
+
const roleBadgeColor = (roleId: string): ModelAssignmentBadge["color"] =>
|
|
2303
|
+
roleId === "executor" || roleId === "architect" || roleId === "planner" || roleId === "critic" ? roleId : "critic";
|
|
2304
|
+
|
|
2305
|
+
const orderedModelRoles = (config: Awaited<ReturnType<typeof readGlobalConfig>>) => {
|
|
2306
|
+
const roles = allSubagentRoles(config);
|
|
2307
|
+
const emitted = new Set<string>();
|
|
2308
|
+
const out: ReturnType<typeof allSubagentRoles> = [];
|
|
2309
|
+
for (const id of CORE_MODEL_ACTION_ROLE_ORDER) {
|
|
2310
|
+
const role = roles.find(r => r.id === id);
|
|
2311
|
+
if (role) {
|
|
2312
|
+
emitted.add(role.id);
|
|
2313
|
+
out.push(role);
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
for (const role of roles) {
|
|
2317
|
+
if (!emitted.has(role.id)) out.push(role);
|
|
2318
|
+
}
|
|
2319
|
+
return out;
|
|
2320
|
+
};
|
|
2321
|
+
|
|
2322
|
+
const modelPickerAssignments = async (): Promise<ModelAssignmentBadge[]> => {
|
|
2323
|
+
const cfg = await readGlobalConfig();
|
|
2324
|
+
const defaultModelForBadges = sessionModel || cfg.defaultModel;
|
|
2325
|
+
const cfgForBadges = { ...cfg, defaultModel: defaultModelForBadges };
|
|
2326
|
+
const roles = allSubagentRoles(cfgForBadges);
|
|
2327
|
+
const byId = new Map(roles.map(role => [role.id, role]));
|
|
2328
|
+
const orderedRoleIds = [
|
|
2329
|
+
...MODEL_BADGE_ROLE_ORDER,
|
|
2330
|
+
...roles.map(role => role.id).filter(id => !MODEL_BADGE_ROLE_ORDER.includes(id as (typeof MODEL_BADGE_ROLE_ORDER)[number])),
|
|
2331
|
+
];
|
|
2332
|
+
const assignments: ModelAssignmentBadge[] = [
|
|
2333
|
+
{
|
|
2334
|
+
role: "default",
|
|
2335
|
+
label: "DEFAULT",
|
|
2336
|
+
model: defaultModelForBadges,
|
|
2337
|
+
thinking: sessionThinking ?? cfg.thinkingLevel ?? "medium",
|
|
2338
|
+
color: "default",
|
|
2339
|
+
},
|
|
2340
|
+
];
|
|
2341
|
+
for (const roleId of orderedRoleIds) {
|
|
2342
|
+
const role = byId.get(roleId);
|
|
2343
|
+
if (!role) continue;
|
|
2344
|
+
assignments.push({
|
|
2345
|
+
role: role.id,
|
|
2346
|
+
label: role.title.toUpperCase(),
|
|
2347
|
+
model: resolveSubagentModel(role.id, cfgForBadges),
|
|
2348
|
+
thinking: resolveSubagentThinking(role.id, cfgForBadges) ?? "inherit",
|
|
2349
|
+
color: roleBadgeColor(role.id),
|
|
2350
|
+
});
|
|
2351
|
+
}
|
|
2352
|
+
return assignments;
|
|
2353
|
+
};
|
|
2354
|
+
|
|
2355
|
+
const pickLiveProviderModel = async (
|
|
2356
|
+
providerName: string,
|
|
2357
|
+
entries: PickEntry[],
|
|
2358
|
+
current?: string,
|
|
2359
|
+
disabledProviders: readonly ProviderName[] = [],
|
|
2360
|
+
): Promise<PickEntry | undefined> => {
|
|
2361
|
+
if (!process.stdin.isTTY || entries.length === 0) return undefined;
|
|
2362
|
+
const list = liveModelPicker(entries, { current, assignments: await modelPickerAssignments(), disabledProviders, disabledHint: "needs API key/base URL" });
|
|
2363
|
+
let chosen: PickEntry | undefined;
|
|
2364
|
+
await runSelectPicker(
|
|
2365
|
+
(cols, rows) =>
|
|
2366
|
+
renderLiveModelPicker(list, {
|
|
2367
|
+
title: `Select ${providerName} model`,
|
|
2368
|
+
cols,
|
|
2369
|
+
rows: Math.max(4, Math.min(rows, 12)),
|
|
2370
|
+
unicode: true,
|
|
2371
|
+
color: true,
|
|
2372
|
+
}),
|
|
2373
|
+
(ch, key) => {
|
|
2374
|
+
if (key?.name === "up") {
|
|
2375
|
+
list.up();
|
|
2376
|
+
return false;
|
|
2377
|
+
}
|
|
2378
|
+
if (key?.name === "down") {
|
|
2379
|
+
list.down();
|
|
2380
|
+
return false;
|
|
2381
|
+
}
|
|
2382
|
+
if (key?.name === "pageup") {
|
|
2383
|
+
list.page(-1, 6);
|
|
2384
|
+
return false;
|
|
2385
|
+
}
|
|
2386
|
+
if (key?.name === "pagedown") {
|
|
2387
|
+
list.page(1, 6);
|
|
2388
|
+
return false;
|
|
2389
|
+
}
|
|
2390
|
+
if (key?.name === "backspace") {
|
|
2391
|
+
list.backspace();
|
|
2392
|
+
return false;
|
|
2393
|
+
}
|
|
2394
|
+
if (key?.name === "escape" || (key?.ctrl && key.name === "c")) {
|
|
2395
|
+
return true;
|
|
2396
|
+
}
|
|
2397
|
+
if (key?.name === "return" || key?.name === "enter") {
|
|
2398
|
+
chosen = list.selected()?.value;
|
|
2399
|
+
return true;
|
|
2400
|
+
}
|
|
2401
|
+
if (ch && ch >= " " && !key?.ctrl && !key?.meta) {
|
|
2402
|
+
list.typeChar(ch);
|
|
2403
|
+
}
|
|
2404
|
+
return false;
|
|
2405
|
+
},
|
|
2406
|
+
);
|
|
2407
|
+
return chosen;
|
|
2408
|
+
};
|
|
2409
|
+
|
|
2410
|
+
/** Generic arrows+Enter option picker (apply-target / role / action menus).
|
|
2411
|
+
* Returns the chosen value, or undefined on ESC / non-TTY. */
|
|
2412
|
+
const pickFromOptions = async (
|
|
2413
|
+
title: string,
|
|
2414
|
+
options: SelectItem<string>[],
|
|
2415
|
+
): Promise<string | undefined> => {
|
|
2416
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY || options.length === 0) return undefined;
|
|
2417
|
+
const list = new SelectList(options);
|
|
2418
|
+
let chosen: string | undefined;
|
|
2419
|
+
await runSelectPicker(
|
|
2420
|
+
(cols, rows) => renderSelectList(list, { title, cols, rows: Math.max(4, Math.min(rows, 14)), unicode: true, color: true }),
|
|
2421
|
+
(ch, key) => {
|
|
2422
|
+
if (key?.name === "up") { list.up(); return false; }
|
|
2423
|
+
if (key?.name === "down") { list.down(); return false; }
|
|
2424
|
+
if (key?.name === "escape" || (key?.ctrl && key.name === "c")) return true;
|
|
2425
|
+
if (key?.name === "return" || key?.name === "enter") {
|
|
2426
|
+
chosen = list.selected()?.value;
|
|
2427
|
+
return true;
|
|
2428
|
+
}
|
|
2429
|
+
if (key?.name === "backspace") { list.backspace(); return false; }
|
|
2430
|
+
if (ch && ch >= " " && !key?.ctrl && !key?.meta) list.typeChar(ch);
|
|
2431
|
+
return false;
|
|
2432
|
+
},
|
|
2433
|
+
);
|
|
2434
|
+
return chosen;
|
|
2435
|
+
};
|
|
2436
|
+
|
|
2437
|
+
const pickThinkingLevel = async (
|
|
2438
|
+
title: string,
|
|
2439
|
+
current: ThinkLevel | undefined,
|
|
2440
|
+
inheritLabel?: string,
|
|
2441
|
+
): Promise<ThinkLevel | "inherit" | undefined> => {
|
|
2442
|
+
const mark = (lvl: string): string => (current === lvl ? "current" : "");
|
|
2443
|
+
const levels: { value: string; label: string; hint?: string }[] = [
|
|
2444
|
+
...(inheritLabel ? [{ value: "inherit", label: inheritLabel, hint: current === undefined ? "current" : "" }] : []),
|
|
2445
|
+
{ value: "minimal", label: "minimal — lightest reasoning", hint: mark("minimal") },
|
|
2446
|
+
{ value: "low", label: `low — light reasoning (~${Math.round(thinkingMaxTokens("low") / 1000)}k tokens)`, hint: mark("low") },
|
|
2447
|
+
{ value: "medium", label: `medium — moderate reasoning (~${Math.round(thinkingMaxTokens("medium") / 1000)}k tokens)`, hint: mark("medium") },
|
|
2448
|
+
{ value: "high", label: `high — deep reasoning (~${Math.round(thinkingMaxTokens("high") / 1000)}k tokens)`, hint: mark("high") },
|
|
2449
|
+
{ value: "xhigh", label: `xhigh — maximum reasoning (~${Math.round(thinkingMaxTokens("xhigh") / 1000)}k tokens)`, hint: mark("xhigh") },
|
|
2450
|
+
];
|
|
2451
|
+
const picked = await pickFromOptions(title, levels);
|
|
2452
|
+
return picked === "inherit" || isThinkingLevel(picked) ? picked : undefined;
|
|
2453
|
+
};
|
|
2454
|
+
|
|
2455
|
+
const setRoleThinking = async (roleId: string, rawLevel: string | undefined): Promise<boolean> => {
|
|
2456
|
+
const cfgForRole = await readGlobalConfig();
|
|
2457
|
+
const role = getSubagentRole(roleId, cfgForRole);
|
|
2458
|
+
if (!role) return false;
|
|
2459
|
+
const level = (rawLevel ?? "").toLowerCase();
|
|
2460
|
+
if (level === "inherit") {
|
|
2461
|
+
await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { thinking: undefined }) }));
|
|
2462
|
+
console.log(`${role.title} thinking now inherits the default → ~/.jeo/config.json`);
|
|
2463
|
+
return true;
|
|
2464
|
+
}
|
|
2465
|
+
if (!isThinkingLevel(level)) {
|
|
2466
|
+
console.log(`Usage: /model subagent ${role.id} thinking <inherit|minimal|low|medium|high|xhigh>`);
|
|
2467
|
+
return true;
|
|
2468
|
+
}
|
|
2469
|
+
await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { thinking: level }) }));
|
|
2470
|
+
console.log(`${role.title} thinking set to ${level} (~${thinkingMaxTokens(level)} max tokens/step) → ~/.jeo/config.json`);
|
|
2471
|
+
return true;
|
|
2472
|
+
};
|
|
2473
|
+
const displayModelName = (model: string): string => {
|
|
2474
|
+
const leaf = (model.split("/").pop() || model).trim();
|
|
2475
|
+
return leaf.replace(/^gpt/i, "GPT");
|
|
2476
|
+
};
|
|
2477
|
+
|
|
2478
|
+
const modelActionChoices = (config: Awaited<ReturnType<typeof readGlobalConfig>>): SelectItem<string>[] => {
|
|
2479
|
+
const levels: ThinkLevel[] = ["minimal", "low", "medium", "high", "xhigh"];
|
|
2480
|
+
const choices: SelectItem<string>[] = [];
|
|
2481
|
+
const currentDefaultThinking = sessionThinking ?? config.thinkingLevel ?? "medium";
|
|
2482
|
+
const appendChildren = (children: Array<Omit<SelectItem<string>, "depth" | "branch">>) => {
|
|
2483
|
+
children.forEach((child, index) => {
|
|
2484
|
+
choices.push({ ...child, depth: 1, branch: index === children.length - 1 ? "last" : "mid" });
|
|
2485
|
+
});
|
|
2486
|
+
};
|
|
2487
|
+
|
|
2488
|
+
choices.push({
|
|
2489
|
+
value: "heading:default",
|
|
2490
|
+
label: "Set as DEFAULT (Default)",
|
|
2491
|
+
hint: `${config.defaultModel} (${currentDefaultThinking})`,
|
|
2492
|
+
disabled: true,
|
|
2493
|
+
});
|
|
2494
|
+
appendChildren([
|
|
2495
|
+
{ value: "default:keep", label: "Set model only", hint: `keep thinking ${currentDefaultThinking}` },
|
|
2496
|
+
...levels.map(level => ({
|
|
2497
|
+
value: `default:${level}`,
|
|
2498
|
+
label: `thinking ${level}`,
|
|
2499
|
+
hint: level === currentDefaultThinking ? "current" : `~${Math.round(thinkingMaxTokens(level) / 1000)}k tokens`,
|
|
2500
|
+
})),
|
|
2501
|
+
]);
|
|
2502
|
+
|
|
2503
|
+
for (const role of orderedModelRoles(config)) {
|
|
2504
|
+
const roleThinking = resolveSubagentThinking(role.id, config) ?? "inherit";
|
|
2505
|
+
choices.push({
|
|
2506
|
+
value: `heading:${role.id}`,
|
|
2507
|
+
label: `Set as ${role.title.toUpperCase()} (${role.title})`,
|
|
2508
|
+
hint: `${resolveSubagentModel(role.id, config)} (${roleThinking})`,
|
|
2509
|
+
disabled: true,
|
|
2510
|
+
});
|
|
2511
|
+
appendChildren([
|
|
2512
|
+
{ value: `${role.id}:keep`, label: "Set model only", hint: `keep thinking ${roleThinking}` },
|
|
2513
|
+
{
|
|
2514
|
+
value: `${role.id}:inherit`,
|
|
2515
|
+
label: "thinking inherit",
|
|
2516
|
+
hint: `follow default (${config.thinkingLevel ?? "medium"})`,
|
|
2517
|
+
},
|
|
2518
|
+
...levels.map(level => ({
|
|
2519
|
+
value: `${role.id}:${level}`,
|
|
2520
|
+
label: `thinking ${level}`,
|
|
2521
|
+
hint: roleThinking === level ? "current" : `~${Math.round(thinkingMaxTokens(level) / 1000)}k tokens`,
|
|
2522
|
+
})),
|
|
2523
|
+
]);
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
choices.push({
|
|
2527
|
+
value: "preset:openai-codex",
|
|
2528
|
+
label: "Apply OpenAI Codex role preset",
|
|
2529
|
+
hint: "Default medium · Executor low · Architect xhigh · Planner medium · Critic high",
|
|
2530
|
+
});
|
|
2531
|
+
return choices;
|
|
2532
|
+
};
|
|
2533
|
+
|
|
2534
|
+
const applyOpenAiCodexRolePreset = async (target: string, cfgForPick: Awaited<ReturnType<typeof readGlobalConfig>>): Promise<void> => {
|
|
2535
|
+
const roleThinking: Record<(typeof CORE_MODEL_ACTION_ROLE_ORDER)[number], ThinkLevel> = {
|
|
2536
|
+
executor: "low",
|
|
2537
|
+
architect: "xhigh",
|
|
2538
|
+
planner: "medium",
|
|
2539
|
+
critic: "high",
|
|
2540
|
+
};
|
|
2541
|
+
await saveConfigPatch(raw => {
|
|
2542
|
+
let subagents = raw.subagents ?? {};
|
|
2543
|
+
for (const roleId of CORE_MODEL_ACTION_ROLE_ORDER) {
|
|
2544
|
+
subagents = withSubagentSetting({ subagents }, roleId, { model: target, thinking: roleThinking[roleId] });
|
|
2545
|
+
}
|
|
2546
|
+
return {
|
|
2547
|
+
...rememberModelPatch(raw, target),
|
|
2548
|
+
thinkingLevel: "medium",
|
|
2549
|
+
subagents,
|
|
2550
|
+
};
|
|
2551
|
+
});
|
|
2552
|
+
sessionModel = target;
|
|
2553
|
+
sessionThinking = "medium";
|
|
2554
|
+
const { resolved, provider } = await describeModel(target);
|
|
2555
|
+
const st = (await describeAllProviders(cfgForPick)).find(s => s.name === provider);
|
|
2556
|
+
console.log(`OpenAI Codex role preset applied to ${formatModelLine({ label: target, resolved, provider, ready: st?.ready })} — Default medium, Executor low, Architect xhigh, Planner medium, Critic high`);
|
|
2557
|
+
};
|
|
2558
|
+
|
|
2559
|
+
|
|
2560
|
+
const applyPickedModelWithTarget = async (target: string): Promise<boolean> => {
|
|
2561
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
|
|
2562
|
+
const cfgForPick = await readGlobalConfig();
|
|
2563
|
+
const choice = await pickFromOptions(`Model Name: ${displayModelName(target)}\n\nAction for: ${target}`, modelActionChoices(cfgForPick)) ?? "default:keep";
|
|
2564
|
+
if (choice === "preset:openai-codex") {
|
|
2565
|
+
await applyOpenAiCodexRolePreset(target, cfgForPick);
|
|
2566
|
+
return true;
|
|
2567
|
+
}
|
|
2568
|
+
const [applyTo, action = "keep"] = choice.split(":", 2);
|
|
2569
|
+
if (applyTo === "heading") return false;
|
|
2570
|
+
const roleTarget = applyTo !== "default" ? getSubagentRole(applyTo, cfgForPick) : undefined;
|
|
2571
|
+
const { resolved, provider } = await describeModel(target);
|
|
2572
|
+
const st = (await describeAllProviders(cfgForPick)).find(s => s.name === provider);
|
|
2573
|
+
if (roleTarget) {
|
|
2574
|
+
const thinkPatch = action === "inherit" ? { thinking: undefined } : isThinkingLevel(action) ? { thinking: action } : {};
|
|
2575
|
+
await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, roleTarget.id, { model: target, ...thinkPatch }) }));
|
|
2576
|
+
const thinkNote = action !== "keep" ? ` · thinking ${action}` : "";
|
|
2577
|
+
console.log(`Subagent '${roleTarget.id}' model set to ${formatModelLine({ label: target, resolved, provider, ready: st?.ready })}${thinkNote} — saved (change anytime via /model or /agents)`);
|
|
2578
|
+
return true;
|
|
2579
|
+
}
|
|
2580
|
+
sessionModel = target;
|
|
2581
|
+
const defaultThinking = isThinkingLevel(action) ? action : undefined;
|
|
2582
|
+
if (defaultThinking) {
|
|
2583
|
+
sessionThinking = defaultThinking;
|
|
2584
|
+
}
|
|
2585
|
+
await saveConfigPatch(raw => ({
|
|
2586
|
+
...rememberModelPatch(raw, target),
|
|
2587
|
+
...(defaultThinking ? { thinkingLevel: defaultThinking } : {}),
|
|
2588
|
+
}));
|
|
2589
|
+
console.log(`Model set to ${formatModelLine({ label: target, resolved, provider, ready: st?.ready })}${defaultThinking ? ` · thinking ${defaultThinking}` : ""} — saved as default`);
|
|
2590
|
+
return true;
|
|
2591
|
+
};
|
|
2592
|
+
|
|
2593
|
+
const pickSkillFromList = async (skills: SkillDoc[]): Promise<SkillDoc | undefined> => {
|
|
2594
|
+
if (!process.stdin.isTTY || skills.length === 0) return undefined;
|
|
2595
|
+
const list = skillPicker(skills);
|
|
2596
|
+
let chosen: SkillDoc | undefined;
|
|
2597
|
+
await runSelectPicker(
|
|
2598
|
+
(cols, rows) =>
|
|
2599
|
+
renderSkillPicker(list, {
|
|
2600
|
+
cols,
|
|
2601
|
+
rows: Math.max(4, Math.min(rows, 12)),
|
|
2602
|
+
unicode: true,
|
|
2603
|
+
color: true,
|
|
2604
|
+
}),
|
|
2605
|
+
(ch, key) => {
|
|
2606
|
+
if (key?.name === "up") {
|
|
2607
|
+
list.up();
|
|
2608
|
+
return false;
|
|
2609
|
+
}
|
|
2610
|
+
if (key?.name === "down") {
|
|
2611
|
+
list.down();
|
|
2612
|
+
return false;
|
|
2613
|
+
}
|
|
2614
|
+
if (key?.name === "pageup") {
|
|
2615
|
+
list.page(-1, 6);
|
|
2616
|
+
return false;
|
|
2617
|
+
}
|
|
2618
|
+
if (key?.name === "pagedown") {
|
|
2619
|
+
list.page(1, 6);
|
|
2620
|
+
return false;
|
|
2621
|
+
}
|
|
2622
|
+
if (key?.name === "backspace") {
|
|
2623
|
+
list.backspace();
|
|
2624
|
+
return false;
|
|
2625
|
+
}
|
|
2626
|
+
if (key?.name === "escape" || (key?.ctrl && key.name === "c")) {
|
|
2627
|
+
return true;
|
|
2628
|
+
}
|
|
2629
|
+
if (key?.name === "return" || key?.name === "enter") {
|
|
2630
|
+
chosen = list.selected()?.value;
|
|
2631
|
+
return true;
|
|
2632
|
+
}
|
|
2633
|
+
if (ch && ch >= " " && !key?.ctrl && !key?.meta) {
|
|
2634
|
+
list.typeChar(ch);
|
|
2635
|
+
}
|
|
2636
|
+
return false;
|
|
2637
|
+
},
|
|
2638
|
+
);
|
|
2639
|
+
return chosen;
|
|
2640
|
+
};
|
|
2641
|
+
|
|
2642
|
+
const pickCloudProvider = async (statuses: Awaited<ReturnType<typeof describeAllProviders>>): Promise<AuthProvider | undefined> => {
|
|
2643
|
+
const cloud = new Set(["anthropic", "openai", "gemini", "antigravity"]);
|
|
2644
|
+
const list = providerPicker(statuses.filter(s => cloud.has(s.name)), true);
|
|
2645
|
+
let chosen: ProviderName | undefined;
|
|
2646
|
+
await runSelectPicker(
|
|
2647
|
+
(cols, rows) =>
|
|
2648
|
+
renderProviderPicker(list, {
|
|
2649
|
+
title: "Select OAuth provider",
|
|
2650
|
+
cols,
|
|
2651
|
+
rows: Math.max(4, Math.min(rows, 8)),
|
|
2652
|
+
unicode: true,
|
|
2653
|
+
color: true,
|
|
2654
|
+
}),
|
|
2655
|
+
(ch, key) => {
|
|
2656
|
+
if (key?.name === "up") {
|
|
2657
|
+
list.up();
|
|
2658
|
+
return false;
|
|
2659
|
+
}
|
|
2660
|
+
if (key?.name === "down") {
|
|
2661
|
+
list.down();
|
|
2662
|
+
return false;
|
|
2663
|
+
}
|
|
2664
|
+
if (key?.name === "pageup") {
|
|
2665
|
+
list.page(-1, 4);
|
|
2666
|
+
return false;
|
|
2667
|
+
}
|
|
2668
|
+
if (key?.name === "pagedown") {
|
|
2669
|
+
list.page(1, 4);
|
|
2670
|
+
return false;
|
|
2671
|
+
}
|
|
2672
|
+
if (key?.name === "backspace") {
|
|
2673
|
+
list.backspace();
|
|
2674
|
+
return false;
|
|
2675
|
+
}
|
|
2676
|
+
if (key?.name === "escape" || (key?.ctrl && key.name === "c")) {
|
|
2677
|
+
return true;
|
|
2678
|
+
}
|
|
2679
|
+
if (key?.name === "return" || key?.name === "enter") {
|
|
2680
|
+
chosen = list.selected()?.value;
|
|
2681
|
+
return true;
|
|
2682
|
+
}
|
|
2683
|
+
if (ch && ch >= " " && !key?.ctrl && !key?.meta) {
|
|
2684
|
+
list.typeChar(ch);
|
|
2685
|
+
}
|
|
2686
|
+
return false;
|
|
2687
|
+
},
|
|
2688
|
+
);
|
|
2689
|
+
return chosen && cloud.has(chosen) ? chosen as AuthProvider : undefined;
|
|
2690
|
+
};
|
|
2691
|
+
|
|
2692
|
+
if (previewEnabled) {
|
|
2693
|
+
process.once("exit", () => out.write("\x1b[?25h")); // safety net: never leave the cursor hidden
|
|
2694
|
+
process.stdin.on("keypress", (_ch: string, key: { name?: string; ctrl?: boolean; meta?: boolean } | undefined) => {
|
|
2695
|
+
if (key?.ctrl && key.name === "c") {
|
|
2696
|
+
forceExitFromCtrlC();
|
|
2697
|
+
return;
|
|
2698
|
+
}
|
|
2699
|
+
if (!previewArmed || pickerActive) return;
|
|
2700
|
+
// Ctrl+O: toggle a reversible history/detail panel. The live-turn TUI path
|
|
2701
|
+
// uses LaunchTui.showDetail(); this idle-prompt path paints the same content
|
|
2702
|
+
// into the fixed footer reservation, so the second Ctrl+O can close it.
|
|
2703
|
+
// (Cmd+O is intercepted by macOS/terminal and never reaches the app.)
|
|
2704
|
+
if (key?.ctrl && key.name === "o") {
|
|
2705
|
+
if (promptHistoryLines) {
|
|
2706
|
+
promptHistoryLines = null;
|
|
2707
|
+
drawFooter(previewLines(typedLine, navIdx));
|
|
2708
|
+
return;
|
|
2709
|
+
}
|
|
2710
|
+
const detail = composeDetailLines();
|
|
2711
|
+
if (detail.length === 0) return;
|
|
2712
|
+
promptHistoryLines = detail;
|
|
2713
|
+
drawFooter(historyPreviewLines(detail));
|
|
2714
|
+
return;
|
|
2715
|
+
}
|
|
2716
|
+
if (promptHistoryLines) promptHistoryLines = null;
|
|
2717
|
+
// Ctrl+V: attach a clipboard IMAGE to the next message. Terminal text paste
|
|
2718
|
+
// never arrives as a ctrl+v keypress (it streams as plain stdin data), so this
|
|
2719
|
+
// binding is image-only; when the clipboard holds no image it's a silent no-op.
|
|
2720
|
+
if (key?.ctrl && key.name === "v") {
|
|
2721
|
+
if (pasteInFlight) return;
|
|
2722
|
+
pasteInFlight = true;
|
|
2723
|
+
void (async () => {
|
|
2724
|
+
try {
|
|
2725
|
+
const img = await readClipboardImage();
|
|
2726
|
+
if (!img) return;
|
|
2727
|
+
pendingImages.push(img);
|
|
2728
|
+
const tag = `[image #${pendingImages.length}]`;
|
|
2729
|
+
const rli = rl as unknown as { line: string; cursor: number };
|
|
2730
|
+
const at = typeof rli.cursor === "number" ? rli.cursor : rli.line.length;
|
|
2731
|
+
const sep = rli.line.length > 0 && at > 0 && rli.line[at - 1] !== " " ? " " : "";
|
|
2732
|
+
rli.line = rli.line.slice(0, at) + sep + tag + " " + rli.line.slice(at);
|
|
2733
|
+
rli.cursor = at + sep.length + tag.length + 1;
|
|
2734
|
+
typedLine = rli.line;
|
|
2735
|
+
if (previewArmed) drawFooter(previewLines(typedLine, navIdx));
|
|
2736
|
+
} finally {
|
|
2737
|
+
pasteInFlight = false;
|
|
2738
|
+
}
|
|
2739
|
+
})();
|
|
2740
|
+
return;
|
|
2741
|
+
}
|
|
2742
|
+
if (previewPending) return;
|
|
2743
|
+
// ESC (or a meta-mapped Cmd+C) at the prompt: wipe the typed text. A bare
|
|
2744
|
+
// ESC decodes as `escape` (meta is set for a lone ESC byte — accept both)
|
|
2745
|
+
// only after readline's escape-sequence timeout, so arrow/wheel sequences
|
|
2746
|
+
// never trigger this.
|
|
2747
|
+
if (key && ((key.name === "escape" && !key.ctrl) || (key.meta && key.name === "c"))) {
|
|
2748
|
+
clearTypedInput();
|
|
2749
|
+
return;
|
|
2750
|
+
}
|
|
2751
|
+
// Ctrl+C hard-exits above; keep this guard for defensive ordering only.
|
|
2752
|
+
if (key?.ctrl && key.name === "c") return;
|
|
2753
|
+
previewPending = true;
|
|
2754
|
+
setImmediate(() => {
|
|
2755
|
+
previewPending = false;
|
|
2756
|
+
if (!previewArmed) return;
|
|
2757
|
+
try {
|
|
2758
|
+
if (key && (key.name === "return" || key.name === "enter")) {
|
|
2759
|
+
promptHistoryLines = null;
|
|
2760
|
+
drawFooter([]);
|
|
2761
|
+
return;
|
|
2762
|
+
}
|
|
2763
|
+
// Arrow up/down: move the highlight over the slash keyword preview list.
|
|
2764
|
+
// Once the user types a real argument (`/subagent `, `/provider login `, ...),
|
|
2765
|
+
// we stop intercepting arrows and just show the live completion preview.
|
|
2766
|
+
if (key && (key.name === "up" || key.name === "down") && navMatches.length > 0) {
|
|
2767
|
+
const rli = rl as unknown as { line: string; cursor: number; _refreshLine?: () => void };
|
|
2768
|
+
if (rli.line !== typedLine) {
|
|
2769
|
+
rli.line = typedLine;
|
|
2770
|
+
rli.cursor = typedLine.length;
|
|
2771
|
+
rli._refreshLine?.();
|
|
2772
|
+
}
|
|
2773
|
+
if (navIdx === -1) navIdx = key.name === "down" ? 0 : navMatches.length - 1;
|
|
2774
|
+
else navIdx = (navIdx + (key.name === "down" ? 1 : -1) + navMatches.length) % navMatches.length;
|
|
2775
|
+
pendingSelection = navMatches[navIdx];
|
|
2776
|
+
drawFooter(previewLines(typedLine, navIdx));
|
|
2777
|
+
return;
|
|
2778
|
+
}
|
|
2779
|
+
// Tab: complete the line to the highlighted popup row (or the TOP match
|
|
2780
|
+
// when nothing is highlighted) — `/mod`+Tab → `/model `, `$sp`+Tab →
|
|
2781
|
+
// `$spec-kit `. Runs AFTER readline's own completer fired; overwriting
|
|
2782
|
+
// rl.line here makes the popup's choice the deterministic final state
|
|
2783
|
+
// (the completer's candidate dump is gated while the preview is armed).
|
|
2784
|
+
if (key && key.name === "tab" && navMatches.length > 0) {
|
|
2785
|
+
const completed = tabCompleteSelection(typedLine, navMatches, navIdx);
|
|
2786
|
+
|
|
2787
|
+
if (completed) {
|
|
2788
|
+
const rli = rl as unknown as { line: string; cursor: number; _refreshLine?: () => void };
|
|
2789
|
+
rli.line = completed;
|
|
2790
|
+
rli.cursor = completed.length;
|
|
2791
|
+
rli._refreshLine?.();
|
|
2792
|
+
typedLine = completed;
|
|
2793
|
+
navMatches = slashPreviewMatches(typedLine, skillSlashDetails, resolvedSkills);
|
|
2794
|
+
navIdx = -1;
|
|
2795
|
+
pendingSelection = undefined;
|
|
2796
|
+
drawFooter(previewLines(typedLine));
|
|
2797
|
+
return;
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
// Any other key edits the line: refresh the slash-keyword matches (if any),
|
|
2801
|
+
// reset the highlight, and show either the command preview or argument preview.
|
|
2802
|
+
typedLine = rl.line;
|
|
2803
|
+
navMatches = slashPreviewMatches(typedLine, skillSlashDetails, resolvedSkills);
|
|
2804
|
+
navIdx = -1;
|
|
2805
|
+
pendingSelection = undefined;
|
|
2806
|
+
drawFooter(previewLines(typedLine));
|
|
2807
|
+
} catch { /* ignore render races */ }
|
|
2808
|
+
});
|
|
2809
|
+
});
|
|
2810
|
+
// Idle-prompt resize: re-reserve the footer at the new terminal height so the
|
|
2811
|
+
// fixed reservation stays accurate (otherwise the next paint would target the
|
|
2812
|
+
// old row count and either over-shoot or under-paint the reserved region).
|
|
2813
|
+
process.stdout.on("resize", () => {
|
|
2814
|
+
if (!previewArmed) return;
|
|
2815
|
+
try {
|
|
2816
|
+
disarmPreview();
|
|
2817
|
+
armPreview();
|
|
2818
|
+
drawFooter(promptHistoryLines ? historyPreviewLines(promptHistoryLines) : previewLines(typedLine, navIdx));
|
|
2819
|
+
} catch { /* ignore resize render races */ }
|
|
2820
|
+
});
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
while (true) {
|
|
2824
|
+
drainPendingTtyInput();
|
|
2825
|
+
// "New input first": queued FULL lines from the previous turn are folded
|
|
2826
|
+
// into the editable prefill (visible in the input box, Enter to run,
|
|
2827
|
+
// Esc/Ctrl+U to discard) instead of auto-executing ahead of fresh input —
|
|
2828
|
+
// the "continues the previous work first" bug. Piped stdin keeps the
|
|
2829
|
+
// legacy in-order auto-serve (scripted runs depend on it).
|
|
2830
|
+
if (process.stdin.isTTY) {
|
|
2831
|
+
const folded = restoreQueuedLinesToPrefill(queuedPromptInput);
|
|
2832
|
+
if (folded > 0) {
|
|
2833
|
+
console.log(chalk.dim(`(restored ${folded} queued input line${folded > 1 ? "s" : ""} into the prompt — Enter to run, Esc to discard)`));
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
// Refresh the status bar's dirty flag once per prompt (one git spawn, not per frame).
|
|
2837
|
+
idleDirtyCount = branch ? gitDirtyCount(cwd) : undefined;
|
|
2838
|
+
const prefilledLine = queuedPromptInput.partial;
|
|
2839
|
+
queuedPromptInput.partial = "";
|
|
2840
|
+
armPreview();
|
|
2841
|
+
// Render the boxed input immediately so the prompt is visible even though
|
|
2842
|
+
// readline's own echo is suppressed. If the user typed while the previous
|
|
2843
|
+
// live turn/subagent was still running, seed that text into readline and the
|
|
2844
|
+
// box instead of dropping it as "noise". A pasted batch's TRAILING partial
|
|
2845
|
+
// (no final newline) survives in readline's own buffer — adopt it as the
|
|
2846
|
+
// visible typed line so the box never hides editable input.
|
|
2847
|
+
const rli = rl as unknown as { line?: string; cursor?: number; _refreshLine?: () => void };
|
|
2848
|
+
const residualPartial = !prefilledLine && typeof rli.line === "string" && rli.line.length > 0 && !/\x1b/.test(rli.line)
|
|
2849
|
+
? rli.line
|
|
2850
|
+
: "";
|
|
2851
|
+
typedLine = prefilledLine || residualPartial;
|
|
2852
|
+
if (prefilledLine) {
|
|
2853
|
+
rli.line = prefilledLine;
|
|
2854
|
+
rli.cursor = prefilledLine.length;
|
|
2855
|
+
rli._refreshLine?.();
|
|
2856
|
+
}
|
|
2857
|
+
navMatches = [];
|
|
2858
|
+
navIdx = -1;
|
|
2859
|
+
drawFooter(previewLines(typedLine));
|
|
2860
|
+
// Box mode: NO raw `jeo>` prompt at all — the boxed footer IS the input UI
|
|
2861
|
+
// (gating already suppresses readline echo, the empty prompt guarantees no
|
|
2862
|
+
// raw CLI input line can ever flash). Legacy prompt only without the box.
|
|
2863
|
+
const rawText = await promptInput(previewEnabled ? "" : "\njeo> ");
|
|
2864
|
+
if (rawText.includes("\u0003")) forceExitFromCtrlC();
|
|
2865
|
+
const raw = rawText.trim();
|
|
2866
|
+
disarmPreview();
|
|
2867
|
+
// Pasted batch command: echo what is about to run (with the remaining queue
|
|
2868
|
+
// depth) so a multi-line paste reads as a visible, ordered script.
|
|
2869
|
+
if (promptServedFromPaste && raw) {
|
|
2870
|
+
const remaining = queuedPromptInput.pastedLines.length;
|
|
2871
|
+
console.log(`${categoryBadge("progress")} ▶ pasted command: ${raw}${remaining > 0 ? chalk.dim(` (+${remaining} queued)`) : ""}`);
|
|
2872
|
+
}
|
|
2873
|
+
// If an arrow-key selection was made over the slash/skill preview, apply it
|
|
2874
|
+
// to the ACTIVE trigger token (which may sit anywhere in the line —
|
|
2875
|
+
// mention-style): only the token is replaced, surrounding text survives.
|
|
2876
|
+
// A leading-token selection therefore still replaces the whole line and
|
|
2877
|
+
// runs as a command, exactly as before.
|
|
2878
|
+
const trigger = activeTriggerToken(raw);
|
|
2879
|
+
let input = pendingSelection && trigger && pendingSelection.startsWith(trigger.token)
|
|
2880
|
+
? raw.slice(0, trigger.start) + pendingSelection
|
|
2881
|
+
: raw;
|
|
2882
|
+
// gjc-parity command aliases (full behavior reuse, no duplicated handlers).
|
|
2883
|
+
if (input === "/login" || input.startsWith("/login ")) input = `/provider login${input.slice("/login".length)}`;
|
|
2884
|
+
else if (input === "/settings") input = "/config";
|
|
2885
|
+
// `/subagent`(s) → the /agents roster/editor (view + change the current
|
|
2886
|
+
// subagent composition: per-role model · thinking · steps).
|
|
2887
|
+
else if (input === "/subagent" || input.startsWith("/subagent ")) input = `/agents${input.slice("/subagent".length)}`;
|
|
2888
|
+
else if (input === "/subagents" || input.startsWith("/subagents ")) input = `/agents${input.slice("/subagents".length)}`;
|
|
2889
|
+
pendingSelection = undefined;
|
|
2890
|
+
navMatches = [];
|
|
2891
|
+
navIdx = -1;
|
|
411
2892
|
if (input === "/exit" || input === "/quit") break;
|
|
412
|
-
if (input === "")
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
console.log("
|
|
419
|
-
|
|
420
|
-
console.log(" /agents [role] [model] - List subagent roles, show one, or pin a role's model (saved)");
|
|
421
|
-
console.log(" /config - Show the effective runtime configuration");
|
|
422
|
-
console.log(" /roles [tier model] - Show or set model role tiers (smol/slow/plan)");
|
|
423
|
-
console.log(" /thinking [level] - Show or set the thinking budget (minimal/low/medium/high/xhigh)");
|
|
424
|
-
console.log(" /view <file> [a-b] - Code view: render a file with line numbers + light highlight");
|
|
425
|
-
console.log(" /diff [file] - Render `git diff` with +/- coloring");
|
|
426
|
-
console.log(" /find <glob> - List files matching a glob");
|
|
427
|
-
console.log(" /search <pat> [glob]- Grep the repo for a pattern");
|
|
428
|
-
console.log(" /sessions - List saved sessions");
|
|
429
|
-
console.log(" /evolve - Simulate and view the agent's evolutionary gallery");
|
|
430
|
-
console.log(" /compact - Summarize older turns to free context");
|
|
431
|
-
console.log(" /exit, /quit - Exit the agent");
|
|
432
|
-
console.log("Tools: read / write / edit / bash / find / search. Sessions persist to .joc/sessions/.");
|
|
433
|
-
const tip = getEvolutionTip(history.length, flags.maxSteps);
|
|
2893
|
+
if (input === "") {
|
|
2894
|
+
if (pendingImages.length === 0) continue;
|
|
2895
|
+
input = "Please look at the attached image(s)."; // image-only submit
|
|
2896
|
+
}
|
|
2897
|
+
if (input === "/" || input === "/?" || input === "/help") {
|
|
2898
|
+
logLines(formatSlashCommandList(input === "/help" ? "/" : input, skillSlashDetails));
|
|
2899
|
+
console.log("Tools: read / write / edit / bash / find / search. Sessions persist to .jeo/sessions/.");
|
|
2900
|
+
const tip = getEvolutionTip(history.length, flags.maxSteps > 0 ? flags.maxSteps : initialStepLimit);
|
|
434
2901
|
console.log(`\n${chalk.cyan("Evolutionary Tip:")} ${tip}`);
|
|
435
2902
|
continue;
|
|
436
2903
|
}
|
|
@@ -440,14 +2907,305 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
440
2907
|
continue;
|
|
441
2908
|
}
|
|
442
2909
|
if (input === "/compact") {
|
|
443
|
-
const
|
|
444
|
-
|
|
2910
|
+
const turnConfig = await readGlobalConfig();
|
|
2911
|
+
const activeModel = sessionModel || turnConfig.defaultModel;
|
|
2912
|
+
const contextTokens = catalogMetadata(activeModel)?.contextTokens;
|
|
2913
|
+
const res = await maybeCompact(history, { model: sessionModel, force: true, contextTokens });
|
|
2914
|
+
if (res.error) {
|
|
2915
|
+
console.error(chalk.red(res.error));
|
|
2916
|
+
} else if (res.compacted && sessionId && res.replacesThrough !== undefined) {
|
|
2917
|
+
const touchedNote = res.touchedFiles?.length ? ` Files touched: ${res.touchedFiles.join(", ")}.` : "";
|
|
2918
|
+
const summaryText = res.summary ?? `[Earlier conversation omitted: ${res.removed} messages — summary unavailable.${touchedNote}]`;
|
|
2919
|
+
await appendCompaction(sessionId, ++compactionSeq, summaryText, res.replacesThrough, cwd);
|
|
2920
|
+
console.log(`(compacted ${res.removed} older messages${res.touchedFiles?.length ? `; kept ${res.touchedFiles.length} file refs` : ""})`);
|
|
2921
|
+
} else {
|
|
2922
|
+
console.log("(nothing to compact)");
|
|
2923
|
+
}
|
|
445
2924
|
continue;
|
|
446
2925
|
}
|
|
447
2926
|
if (input === "/sessions") {
|
|
448
2927
|
const sessions = await listSessions(cwd);
|
|
449
2928
|
if (sessions.length === 0) console.log("(no saved sessions)");
|
|
450
|
-
for (const s of sessions)
|
|
2929
|
+
for (const s of sessions) {
|
|
2930
|
+
const marker = s.id === sessionId ? "*" : " ";
|
|
2931
|
+
const title = s.title ? `[${s.title}] ` : "";
|
|
2932
|
+
console.log(` ${marker}${s.id} (${s.messageCount} msgs) ${title}${s.preview}`);
|
|
2933
|
+
}
|
|
2934
|
+
continue;
|
|
2935
|
+
}
|
|
2936
|
+
// ---- gjc-parity session management ------------------------------------
|
|
2937
|
+
const startFreshSession = async (verb: string): Promise<void> => {
|
|
2938
|
+
history.length = 1;
|
|
2939
|
+
if (!flags.noSession) {
|
|
2940
|
+
sessionId = (await createSession(cwd)).id;
|
|
2941
|
+
console.log(`(${verb} — new session ${sessionId})`);
|
|
2942
|
+
} else {
|
|
2943
|
+
sessionId = undefined;
|
|
2944
|
+
console.log(`(${verb} — sessions disabled)`);
|
|
2945
|
+
}
|
|
2946
|
+
};
|
|
2947
|
+
if (input === "/new") {
|
|
2948
|
+
await startFreshSession("started fresh");
|
|
2949
|
+
continue;
|
|
2950
|
+
}
|
|
2951
|
+
if (input === "/drop") {
|
|
2952
|
+
if (sessionId) {
|
|
2953
|
+
const removed = await deleteSession(sessionId, cwd);
|
|
2954
|
+
console.log(removed ? `(deleted session ${sessionId})` : `(session ${sessionId} already gone)`);
|
|
2955
|
+
}
|
|
2956
|
+
await startFreshSession("dropped");
|
|
2957
|
+
continue;
|
|
2958
|
+
}
|
|
2959
|
+
if (input === "/session" || input.startsWith("/session ")) {
|
|
2960
|
+
const sub = input.substring(8).trim().toLowerCase();
|
|
2961
|
+
if (sub === "delete") {
|
|
2962
|
+
if (!sessionId) {
|
|
2963
|
+
console.log("(sessions are disabled — nothing to delete)");
|
|
2964
|
+
continue;
|
|
2965
|
+
}
|
|
2966
|
+
const removed = await deleteSession(sessionId, cwd);
|
|
2967
|
+
console.log(removed ? `(deleted session ${sessionId})` : `(session ${sessionId} already gone)`);
|
|
2968
|
+
await startFreshSession("dropped");
|
|
2969
|
+
continue;
|
|
2970
|
+
}
|
|
2971
|
+
if (sub && sub !== "info") {
|
|
2972
|
+
console.log("Usage: /session [info|delete]");
|
|
2973
|
+
continue;
|
|
2974
|
+
}
|
|
2975
|
+
if (!sessionId) {
|
|
2976
|
+
console.log("Session: disabled (--no-session)");
|
|
2977
|
+
continue;
|
|
2978
|
+
}
|
|
2979
|
+
const all = await listSessions(cwd);
|
|
2980
|
+
const current = all.find(s => s.id === sessionId);
|
|
2981
|
+
console.log("Session info:");
|
|
2982
|
+
console.log(` id ${sessionId}`);
|
|
2983
|
+
if (current?.title) console.log(` title ${current.title}`);
|
|
2984
|
+
console.log(` file ${sessionPath(sessionId, cwd)}`);
|
|
2985
|
+
console.log(` started ${current?.timestamp ?? "(this run)"}`);
|
|
2986
|
+
console.log(` messages ${current?.messageCount ?? Math.max(0, history.length - 1)} persisted · ${history.length - 1} in context`);
|
|
2987
|
+
console.log(` workspace ${cwd}`);
|
|
2988
|
+
continue;
|
|
2989
|
+
}
|
|
2990
|
+
if (input === "/rename" || input.startsWith("/rename ")) {
|
|
2991
|
+
const title = input.substring(7).trim();
|
|
2992
|
+
if (!title) {
|
|
2993
|
+
console.log("Usage: /rename <title>");
|
|
2994
|
+
continue;
|
|
2995
|
+
}
|
|
2996
|
+
if (!sessionId) {
|
|
2997
|
+
console.log("(sessions are disabled — nothing to rename)");
|
|
2998
|
+
continue;
|
|
2999
|
+
}
|
|
3000
|
+
try {
|
|
3001
|
+
await renameSession(sessionId, title, cwd);
|
|
3002
|
+
console.log(`(session renamed to '${title}')`);
|
|
3003
|
+
} catch (err) {
|
|
3004
|
+
console.log(`! rename failed: ${(err as Error).message}`);
|
|
3005
|
+
}
|
|
3006
|
+
continue;
|
|
3007
|
+
}
|
|
3008
|
+
if (input === "/resume" || input.startsWith("/resume ")) {
|
|
3009
|
+
const id = input.substring(7).trim();
|
|
3010
|
+
if (!id) {
|
|
3011
|
+
const sessions = await listSessions(cwd);
|
|
3012
|
+
if (sessions.length === 0) {
|
|
3013
|
+
console.log("(no saved sessions)");
|
|
3014
|
+
continue;
|
|
3015
|
+
}
|
|
3016
|
+
console.log("Saved sessions — resume with /resume <id>:");
|
|
3017
|
+
for (const s of sessions.slice(0, 15)) {
|
|
3018
|
+
const marker = s.id === sessionId ? "*" : " ";
|
|
3019
|
+
console.log(` ${marker}${s.id} (${s.messageCount} msgs) ${s.title ? `[${s.title}] ` : ""}${s.preview}`);
|
|
3020
|
+
}
|
|
3021
|
+
continue;
|
|
3022
|
+
}
|
|
3023
|
+
try {
|
|
3024
|
+
const { messages } = await loadSession(id, cwd);
|
|
3025
|
+
history.length = 1;
|
|
3026
|
+
for (const m of messages) history.push(m);
|
|
3027
|
+
sessionId = id;
|
|
3028
|
+
console.log(`Resumed session ${id} (${messages.length} messages).`);
|
|
3029
|
+
} catch (err) {
|
|
3030
|
+
console.log(`! ${(err as Error).message}`);
|
|
3031
|
+
}
|
|
3032
|
+
continue;
|
|
3033
|
+
}
|
|
3034
|
+
if (input === "/retry") {
|
|
3035
|
+
if (!lastUserInput) {
|
|
3036
|
+
console.log("(nothing to retry yet — send a request first)");
|
|
3037
|
+
continue;
|
|
3038
|
+
}
|
|
3039
|
+
console.log(`(retrying: ${lastUserInput.slice(0, 80)}${lastUserInput.length > 80 ? "…" : ""})`);
|
|
3040
|
+
try {
|
|
3041
|
+
const { done, steps, reply, rendered, usage } = await runTurn(lastUserInput, useTui);
|
|
3042
|
+
lastReply = reply;
|
|
3043
|
+
if (!rendered) {
|
|
3044
|
+
console.log(`jeo> ${renderMarkdownTables(reply)}${usage}`);
|
|
3045
|
+
if (!done) console.log(`(agent did not converge in ${steps} steps)`);
|
|
3046
|
+
} else if (usage) {
|
|
3047
|
+
console.log(usage.trim());
|
|
3048
|
+
}
|
|
3049
|
+
} catch (err) {
|
|
3050
|
+
console.log(`! ${friendlyProviderError(err)}`);
|
|
3051
|
+
}
|
|
3052
|
+
continue;
|
|
3053
|
+
}
|
|
3054
|
+
if (input === "/history" || input.startsWith("/history ")) {
|
|
3055
|
+
// Re-print the worked history into scrollback (tmux-friendly review of
|
|
3056
|
+
// past prompts / tool steps / replies without terminal scrollback access).
|
|
3057
|
+
const arg = input.slice("/history".length).trim().toLowerCase();
|
|
3058
|
+
const maxTurns = arg === "all" ? undefined : Math.max(1, Number.parseInt(arg, 10) || 5);
|
|
3059
|
+
const sep = "─".repeat(Math.min(48, Math.max(20, (process.stdout.columns ?? 80) - 1)));
|
|
3060
|
+
logLines([sep, `history · last ${maxTurns ?? "all"} turn(s) (/history all for everything)`, sep,
|
|
3061
|
+
...formatTranscript(history, { maxTurns, color: true, unicode: true }), sep]);
|
|
3062
|
+
continue;
|
|
3063
|
+
}
|
|
3064
|
+
if (input === "/export" || input.startsWith("/export ")) {
|
|
3065
|
+
if (!sessionId) {
|
|
3066
|
+
console.log("(sessions are disabled — nothing to export)");
|
|
3067
|
+
continue;
|
|
3068
|
+
}
|
|
3069
|
+
const tokens = input.substring(7).trim().split(/\s+/).filter(Boolean);
|
|
3070
|
+
const fmtToken = tokens.find(t => t.toLowerCase() === "json" || t.toLowerCase() === "markdown");
|
|
3071
|
+
const format = fmtToken?.toLowerCase() === "json" ? "json" as const : "markdown" as const;
|
|
3072
|
+
const pathToken = tokens.find(t => t !== fmtToken);
|
|
3073
|
+
const outPath = path.resolve(cwd, pathToken ?? `jeo-session-${sessionId.slice(0, 8)}.${format === "json" ? "json" : "md"}`);
|
|
3074
|
+
try {
|
|
3075
|
+
const text = await exportSession(sessionId, format, cwd);
|
|
3076
|
+
await fs.promises.writeFile(outPath, text, "utf-8");
|
|
3077
|
+
console.log(`${categoryBadge("file")} exported ${format} transcript → ${outPath}`);
|
|
3078
|
+
} catch (err) {
|
|
3079
|
+
console.log(`! export failed: ${(err as Error).message}`);
|
|
3080
|
+
}
|
|
3081
|
+
continue;
|
|
3082
|
+
}
|
|
3083
|
+
if (input === "/dump") {
|
|
3084
|
+
if (!sessionId) {
|
|
3085
|
+
console.log("(sessions are disabled — nothing to dump)");
|
|
3086
|
+
continue;
|
|
3087
|
+
}
|
|
3088
|
+
try {
|
|
3089
|
+
const text = await exportSession(sessionId, "markdown", cwd);
|
|
3090
|
+
const clip = process.platform === "darwin" ? "pbcopy" : Bun.which("wl-copy") ? "wl-copy" : Bun.which("xclip") ? "xclip" : "";
|
|
3091
|
+
if (clip && Bun.which(clip)) {
|
|
3092
|
+
const proc = Bun.spawn(clip === "xclip" ? [clip, "-selection", "clipboard"] : [clip], { stdin: "pipe" });
|
|
3093
|
+
proc.stdin.write(text);
|
|
3094
|
+
await proc.stdin.end();
|
|
3095
|
+
await proc.exited;
|
|
3096
|
+
console.log(`(transcript copied to clipboard — ${text.length} chars)`);
|
|
3097
|
+
} else {
|
|
3098
|
+
console.log(text);
|
|
3099
|
+
console.log("(no clipboard tool found — transcript printed above)");
|
|
3100
|
+
}
|
|
3101
|
+
} catch (err) {
|
|
3102
|
+
console.log(`! dump failed: ${(err as Error).message}`);
|
|
3103
|
+
}
|
|
3104
|
+
continue;
|
|
3105
|
+
}
|
|
3106
|
+
if (input === "/btw" || input.startsWith("/btw ")) {
|
|
3107
|
+
const q = input.substring(4).trim();
|
|
3108
|
+
if (!q) {
|
|
3109
|
+
console.log("Usage: /btw <question> (ephemeral side question — history stays untouched)");
|
|
3110
|
+
continue;
|
|
3111
|
+
}
|
|
3112
|
+
try {
|
|
3113
|
+
const side: Message[] = [
|
|
3114
|
+
{ role: "system", content: "You are jeo. Answer the user's side question concisely in plain text using the conversation context. Do not call tools; reply directly." },
|
|
3115
|
+
...history.slice(1).filter(m => m.role === "user" || m.role === "assistant").slice(-20),
|
|
3116
|
+
{ role: "user", content: q },
|
|
3117
|
+
];
|
|
3118
|
+
// Side questions are quick lookups: STREAM the reply (immediate feedback
|
|
3119
|
+
// instead of a silent 10-30s wait that reads as "broken") and cap the
|
|
3120
|
+
// reasoning budget at LOW — an xhigh session level made slow models burn
|
|
3121
|
+
// a huge thinking budget before the first byte.
|
|
3122
|
+
process.stdout.write("btw> ");
|
|
3123
|
+
let streamed = "";
|
|
3124
|
+
const answer = await callLlm(side, {
|
|
3125
|
+
model: sessionModel,
|
|
3126
|
+
maxTokens: thinkingMaxTokens("low"),
|
|
3127
|
+
onToken: delta => {
|
|
3128
|
+
streamed += delta;
|
|
3129
|
+
process.stdout.write(delta);
|
|
3130
|
+
},
|
|
3131
|
+
});
|
|
3132
|
+
if (!streamed.trim() && answer.trim()) process.stdout.write(answer.trim());
|
|
3133
|
+
process.stdout.write("\n");
|
|
3134
|
+
} catch (err) {
|
|
3135
|
+
console.log(`\n! ${friendlyProviderError(err)}`);
|
|
3136
|
+
}
|
|
3137
|
+
continue;
|
|
3138
|
+
}
|
|
3139
|
+
// ---- gjc-parity inspection commands ------------------------------------
|
|
3140
|
+
if (input === "/usage") {
|
|
3141
|
+
const total = sessionUsage.inputTokens + sessionUsage.outputTokens;
|
|
3142
|
+
console.log("Provider token usage (this REPL):");
|
|
3143
|
+
console.log(` turns ${sessionUsage.turns}`);
|
|
3144
|
+
console.log(` input ${sessionUsage.inputTokens}`);
|
|
3145
|
+
console.log(` output ${sessionUsage.outputTokens}`);
|
|
3146
|
+
console.log(` total ${total}${total === 0 ? " (providers report usage per turn; run a request first)" : ""}`);
|
|
3147
|
+
continue;
|
|
3148
|
+
}
|
|
3149
|
+
if (input === "/context") {
|
|
3150
|
+
// Token estimate (~4 chars/token) over the in-memory history, by role.
|
|
3151
|
+
const est = (s: string) => Math.ceil(s.length / 4);
|
|
3152
|
+
const byRole: Record<string, { msgs: number; tokens: number }> = {};
|
|
3153
|
+
for (const m of history) {
|
|
3154
|
+
const slot = (byRole[m.role] ??= { msgs: 0, tokens: 0 });
|
|
3155
|
+
slot.msgs++;
|
|
3156
|
+
slot.tokens += est(m.content);
|
|
3157
|
+
}
|
|
3158
|
+
const total = Object.values(byRole).reduce((sum, r) => sum + r.tokens, 0);
|
|
3159
|
+
const { resolved } = await describeModel(sessionModel || (await readGlobalConfig()).defaultModel);
|
|
3160
|
+
const window = catalogMetadata(resolved)?.contextTokens;
|
|
3161
|
+
console.log("Context usage (estimated, ~4 chars/token):");
|
|
3162
|
+
for (const [role, r] of Object.entries(byRole)) {
|
|
3163
|
+
console.log(` ${role.padEnd(9)} ${String(r.msgs).padStart(3)} msg${r.msgs === 1 ? " " : "s"} ~${r.tokens} tokens`);
|
|
3164
|
+
}
|
|
3165
|
+
console.log(` ${"total".padEnd(9)} ${String(history.length).padStart(3)} msgs ~${total} tokens${window ? ` (${Math.round((total / window) * 100)}% of ${resolved}'s ${window}-token window)` : ""}`);
|
|
3166
|
+
console.log(" Free context with /compact or /clear.");
|
|
3167
|
+
continue;
|
|
3168
|
+
}
|
|
3169
|
+
if (input === "/tools") {
|
|
3170
|
+
console.log("Tools visible to the agent:");
|
|
3171
|
+
for (const line of TOOL_PROTOCOL.split("\n")) console.log(` ${line}`);
|
|
3172
|
+
console.log(` ${taskToolProtocolLine(await readGlobalConfig())}`);
|
|
3173
|
+
console.log(` ${TODO_TOOL_PROTOCOL_LINE}`);
|
|
3174
|
+
continue;
|
|
3175
|
+
}
|
|
3176
|
+
if (input === "/hotkeys") {
|
|
3177
|
+
console.log("Keyboard shortcuts:");
|
|
3178
|
+
console.log(" Tab complete slash commands, models, roles, @paths");
|
|
3179
|
+
console.log(" ↑ / ↓ navigate the slash-command preview (Enter runs the highlighted one)");
|
|
3180
|
+
console.log(" Enter submit input / confirm picker selection");
|
|
3181
|
+
console.log(" Esc cancel an open picker");
|
|
3182
|
+
console.log(" Ctrl-C cancel the in-flight turn (press again at the prompt to exit)");
|
|
3183
|
+
console.log(" Ctrl-D exit the REPL");
|
|
3184
|
+
console.log(" Ctrl-O dump the full last response (untruncated, tables rendered) into scrollback");
|
|
3185
|
+
console.log(" Ctrl-K / Ctrl-U / Ctrl-W kill to end / start of line / previous word (emacs kill-ring)");
|
|
3186
|
+
console.log(" Ctrl-Y / Alt-Y yank / yank-pop the killed text");
|
|
3187
|
+
console.log(" Ctrl-A / Ctrl-E move to start / end of line");
|
|
3188
|
+
console.log(" / open the slash-command palette");
|
|
3189
|
+
console.log(" @path mention a file (Tab completes relative paths)");
|
|
3190
|
+
continue;
|
|
3191
|
+
}
|
|
3192
|
+
if (input === "/theme" || input.startsWith("/theme ")) {
|
|
3193
|
+
const want = input.substring(6).trim().toLowerCase();
|
|
3194
|
+
const themes = listThemes();
|
|
3195
|
+
if (!want) {
|
|
3196
|
+
const active = resolveTheme().name;
|
|
3197
|
+
console.log("TUI themes (set with /theme <name>, persists via ~/.jeo/config.json):");
|
|
3198
|
+
for (const t of themes) console.log(` ${t.name === active ? "*" : " "} ${t.name.padEnd(10)} ${t.description}`);
|
|
3199
|
+
continue;
|
|
3200
|
+
}
|
|
3201
|
+
if (!themes.some(t => t.name === want)) {
|
|
3202
|
+
console.log(`Unknown theme '${want}'. Known: ${themes.map(t => t.name).join(", ")}.`);
|
|
3203
|
+
continue;
|
|
3204
|
+
}
|
|
3205
|
+
process.env.JEO_TUI_THEME = want;
|
|
3206
|
+
await saveConfigPatch(raw => ({ theme: want }));
|
|
3207
|
+
refreshUiTheme(); // re-resolve the keystroke-hot theme handle immediately
|
|
3208
|
+
console.log(`Theme set to ${want} — saved to ~/.jeo/config.json`);
|
|
451
3209
|
continue;
|
|
452
3210
|
}
|
|
453
3211
|
if (input === "/evolve") {
|
|
@@ -459,104 +3217,340 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
459
3217
|
console.log("\n=== Evolved to Singularity! ===");
|
|
460
3218
|
continue;
|
|
461
3219
|
}
|
|
462
|
-
if (input.startsWith("/models") && (input === "/models" || input[7] === " ")) {
|
|
463
|
-
const sub = input.substring(7).trim().toLowerCase();
|
|
464
|
-
const refresh = sub === "refresh";
|
|
465
|
-
if (sub === "caps") {
|
|
466
|
-
const live = await getLiveModels();
|
|
467
|
-
const def = sessionModel || (await readGlobalConfig()).defaultModel;
|
|
468
|
-
const { resolved } = await describeModel(def);
|
|
469
|
-
console.log("Live models with capabilities (ctx/out/thinking/img):");
|
|
470
|
-
for (const line of formatEnrichedModels(sortByCapability(enrichAll(live)), { current: resolved })) console.log(line);
|
|
471
|
-
continue;
|
|
472
|
-
}
|
|
473
|
-
const cfgNow = await readGlobalConfig();
|
|
474
|
-
const def = sessionModel || cfgNow.defaultModel;
|
|
475
|
-
const { resolved, provider } = await describeModel(def);
|
|
476
|
-
console.log(`Default model: ${formatModelLine({ label: def, resolved, provider })}`);
|
|
477
|
-
console.log("Aliases:");
|
|
478
|
-
for (const line of formatAliasLines(await listAliases())) console.log(line);
|
|
479
|
-
const live = await getLiveModels(refresh);
|
|
480
|
-
lastPickIndex = flattenModels(live);
|
|
481
|
-
console.log("Live models (logged-in providers) — select with /model #N:");
|
|
482
|
-
for (const line of formatPickList(lastPickIndex, { current: resolved })) console.log(line);
|
|
483
|
-
console.log("Refresh: /models refresh · one provider: /provider <name>");
|
|
484
|
-
continue;
|
|
485
|
-
}
|
|
486
3220
|
if (input.startsWith("/provider") && (input === "/provider" || input[9] === " ")) {
|
|
487
3221
|
const tokens = input.substring(9).trim().split(/\s+/).filter(Boolean);
|
|
488
3222
|
const name = (tokens[0] ?? "").toLowerCase();
|
|
489
3223
|
const explicitModel = tokens[1];
|
|
3224
|
+
// `/provider login|auth [name]` → run OAuth login from the REPL.
|
|
3225
|
+
if (name === "login" || name === "auth") {
|
|
3226
|
+
const cloud = ["anthropic", "openai", "gemini", "antigravity"] as const;
|
|
3227
|
+
let target = tokens.slice(1).map(t => t.toLowerCase()).find(t => (cloud as readonly string[]).includes(t));
|
|
3228
|
+
if (!target) {
|
|
3229
|
+
const statuses = await describeAllProviders();
|
|
3230
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
3231
|
+
target = await pickCloudProvider(statuses);
|
|
3232
|
+
} else {
|
|
3233
|
+
// No provider given → show current status and let the user pick.
|
|
3234
|
+
console.log("Log in to which provider?");
|
|
3235
|
+
cloud.forEach((p, i) => {
|
|
3236
|
+
const st = statuses.find(s => s.name === p);
|
|
3237
|
+
console.log(` ${i + 1}) ${p.padEnd(10)} ${st?.ready ? `✓ ${st.label}` : "· not ready"}`);
|
|
3238
|
+
});
|
|
3239
|
+
const ans = (await promptInput("Choose [1-3] or name (blank to cancel): ")).trim().toLowerCase();
|
|
3240
|
+
const byNum: Record<string, string> = { "1": "anthropic", "2": "openai", "3": "gemini" };
|
|
3241
|
+
target = byNum[ans] ?? ((cloud as readonly string[]).includes(ans) ? ans : undefined);
|
|
3242
|
+
}
|
|
3243
|
+
if (!target) {
|
|
3244
|
+
console.log("(cancelled)");
|
|
3245
|
+
continue;
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
console.log(`Starting OAuth login for ${target}…`);
|
|
3249
|
+
try {
|
|
3250
|
+
const { email } = await interactiveOAuthLogin(target as AuthProvider, rl);
|
|
3251
|
+
console.log(`[SUCCESS] OAuth login complete for ${target}${email ? ` (${email})` : ""}. Tokens saved to ~/.jeo/config.json.`);
|
|
3252
|
+
const live = await refreshLiveModelsCache();
|
|
3253
|
+
const after = (await describeAllProviders()).find(s => s.name === target);
|
|
3254
|
+
if (after) console.log(` status → ${after.name}: ${after.ready ? `✓ ${after.label}` : after.label}`);
|
|
3255
|
+
const forProvider = live.filter(r => r.provider === target);
|
|
3256
|
+
if (forProvider.some(r => r.ok && r.models.length > 0)) {
|
|
3257
|
+
lastPickIndex = flattenModels(forProvider);
|
|
3258
|
+
const viaCatalog = forProvider.some(r => r.fallback);
|
|
3259
|
+
console.log(` ${viaCatalog ? "catalog" : "live"} ${target} models → /model #N or /provider ${target} #N${viaCatalog ? " (live list endpoint rejected this token; showing known models)" : ""}`);
|
|
3260
|
+
logLines(formatPickListWithCapabilities(lastPickIndex, { cap: 12 }));
|
|
3261
|
+
} else {
|
|
3262
|
+
const failed = forProvider.find(r => !r.ok);
|
|
3263
|
+
if (failed?.error) console.log(` live ${target} models unavailable: ${failed.error}`);
|
|
3264
|
+
}
|
|
3265
|
+
} catch (err) {
|
|
3266
|
+
console.log(`[FAILED] ${(err as Error).message} — or set ${target.toUpperCase()}_API_KEY.`);
|
|
3267
|
+
}
|
|
3268
|
+
continue;
|
|
3269
|
+
}
|
|
490
3270
|
const cfgNow = await readGlobalConfig();
|
|
491
3271
|
const statuses = await describeAllProviders(cfgNow);
|
|
492
3272
|
if (!name) {
|
|
493
3273
|
console.log("Providers (credential · base URL):");
|
|
494
|
-
|
|
495
|
-
console.log("Switch with: /provider <name> [model] ·
|
|
3274
|
+
logLines(formatProviderPanel(statuses));
|
|
3275
|
+
console.log("Switch with: /provider <name> [model] · arrows+Enter picker: /provider <name> · choose models: /model");
|
|
496
3276
|
continue;
|
|
497
3277
|
}
|
|
498
|
-
|
|
499
|
-
if (!(name in PROVIDER_DEFAULT)) {
|
|
3278
|
+
if (!isProviderName(name)) {
|
|
500
3279
|
console.log(`Unknown provider '${name}'. Known: ${statuses.map(s => s.name).join(", ")}.`);
|
|
501
3280
|
continue;
|
|
502
3281
|
}
|
|
503
3282
|
const st = statuses.find(s => s.name === name);
|
|
504
3283
|
if (st && !st.ready) {
|
|
505
|
-
console.log(`! ${name} is not
|
|
3284
|
+
console.log(`! ${name} is not ready (${st.label}) — set ${st.envVar ?? "the provider key"} or configure a compatible base URL. Switching anyway.`);
|
|
506
3285
|
}
|
|
507
|
-
const target = explicitModel ?? PROVIDER_DEFAULT[name];
|
|
508
|
-
sessionModel = target;
|
|
509
|
-
const { resolved, provider } = await describeModel(target);
|
|
510
|
-
console.log(`Model set to ${formatModelLine({ label: target, resolved, provider, ready: st?.ready })}`);
|
|
511
|
-
// Show the provider's live, credentialed catalog so the user can pick a concrete id.
|
|
512
3286
|
const live = await getLiveModels();
|
|
513
3287
|
const forProvider = live.filter(r => r.provider === name);
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
3288
|
+
const providerPick = flattenModels(forProvider);
|
|
3289
|
+
const currentResolved = (await describeModel(sessionModel || cfgNow.defaultModel)).resolved;
|
|
3290
|
+
let pickedFromPicker = false;
|
|
3291
|
+
let target = explicitModel ?? PROVIDER_DEFAULT[name];
|
|
3292
|
+
if (!explicitModel && providerPick.length && process.stdin.isTTY && process.stdout.isTTY) {
|
|
3293
|
+
const picked = await pickLiveProviderModel(name, providerPick, currentResolved, st && !st.ready && !selectableThoughNotReady(st) ? [name] : []);
|
|
3294
|
+
if (!picked) {
|
|
3295
|
+
console.log("(cancelled)");
|
|
3296
|
+
continue;
|
|
3297
|
+
}
|
|
3298
|
+
pickedFromPicker = true;
|
|
3299
|
+
target = qualifyModelId(picked.model, picked.provider);
|
|
3300
|
+
} else if (explicitModel && providerPick.length) {
|
|
3301
|
+
const sel = resolveSelection(providerPick, explicitModel);
|
|
3302
|
+
if (sel.kind === "index" || sel.kind === "match") {
|
|
3303
|
+
target = qualifyModelId(sel.entry.model, sel.entry.provider);
|
|
3304
|
+
if (st && !st.ready) {
|
|
3305
|
+
if (selectableThoughNotReady(st)) {
|
|
3306
|
+
console.log(notReadyWarning(st));
|
|
3307
|
+
} else {
|
|
3308
|
+
console.log(`Cannot select ${sel.entry.model}: ${name} is not ready (${st.label}). Set ${st.envVar ?? "the provider key"} first.`);
|
|
3309
|
+
continue;
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
} else if (sel.kind === "ambiguous") {
|
|
3313
|
+
console.log(`'${explicitModel}' matches ${sel.matches.length} ${name} models — be more specific:`);
|
|
3314
|
+
for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
|
|
3315
|
+
continue;
|
|
3316
|
+
} else if (sel.kind === "out-of-range") {
|
|
3317
|
+
console.log(`#${explicitModel.slice(1)} is out of range for ${name} (1-${sel.max}).`);
|
|
3318
|
+
continue;
|
|
3319
|
+
}
|
|
3320
|
+
} else if (explicitModel?.startsWith("#")) {
|
|
3321
|
+
console.log(`No numbered ${name} model list is available yet.`);
|
|
3322
|
+
continue;
|
|
518
3323
|
}
|
|
519
|
-
|
|
520
|
-
|
|
3324
|
+
const { resolved, provider } = await describeModel(target);
|
|
3325
|
+
if (explicitModel && provider !== name) {
|
|
3326
|
+
console.log(`! '${target}' resolves to ${provider}, not ${name}. Pick a ${name} model from the live list below.`);
|
|
3327
|
+
if (providerPick.length) logLines(formatPickListWithCapabilities(providerPick, { cap: 20 }));
|
|
3328
|
+
continue;
|
|
3329
|
+
}
|
|
3330
|
+
if (pickedFromPicker && await applyPickedModelWithTarget(target)) {
|
|
3331
|
+
if (providerPick.length) lastPickIndex = providerPick;
|
|
3332
|
+
continue;
|
|
3333
|
+
}
|
|
3334
|
+
sessionModel = target;
|
|
3335
|
+
// MRU persistence: a provider/model pick becomes the default for EVERY
|
|
3336
|
+
// future session and the head of the recents rotation.
|
|
3337
|
+
await saveConfigPatch(raw => rememberModelPatch(raw, target));
|
|
3338
|
+
console.log(`Model set to ${formatModelLine({ label: target, resolved, provider, ready: st?.ready })} — saved as default`);
|
|
3339
|
+
// Show the provider's live, credentialed catalog so the user can pick a concrete id.
|
|
3340
|
+
if (providerPick.length) {
|
|
3341
|
+
lastPickIndex = providerPick;
|
|
3342
|
+
if (!pickedFromPicker) {
|
|
3343
|
+
console.log(`Live ${name} models — select with /model #N, /provider ${name} #N, or rerun /provider ${name} and use arrows+Enter:`);
|
|
3344
|
+
logLines(formatPickListWithCapabilities(lastPickIndex, { current: resolved }));
|
|
3345
|
+
}
|
|
521
3346
|
}
|
|
522
3347
|
continue;
|
|
523
3348
|
}
|
|
524
|
-
if (input.startsWith("/
|
|
525
|
-
const
|
|
3349
|
+
if (input.startsWith("/logout") && (input === "/logout" || input[7] === " ")) {
|
|
3350
|
+
const cloud = ["anthropic", "openai", "gemini", "antigravity"];
|
|
3351
|
+
const target = input.substring(7).trim().split(/\s+/).map(t => t.toLowerCase()).find(t => cloud.includes(t));
|
|
3352
|
+
if (!target) {
|
|
3353
|
+
console.log("Usage: /logout <anthropic|openai|gemini|antigravity>");
|
|
3354
|
+
continue;
|
|
3355
|
+
}
|
|
3356
|
+
const removed = await logoutOAuth(target as AuthProvider);
|
|
3357
|
+
console.log(removed ? `[SUCCESS] Removed OAuth token for ${target}.` : `No OAuth token stored for ${target}.`);
|
|
3358
|
+
await refreshLiveModelsCache();
|
|
3359
|
+
continue;
|
|
3360
|
+
}
|
|
3361
|
+
const agentsCommand =
|
|
3362
|
+
input === "/agents" || input.startsWith("/agents ") ? "/agents" :
|
|
3363
|
+
undefined;
|
|
3364
|
+
if (agentsCommand) {
|
|
3365
|
+
const tokens = input.substring(agentsCommand.length).trim().split(/\s+/).filter(Boolean);
|
|
526
3366
|
const roleArg = tokens[0];
|
|
527
3367
|
const modelArg = tokens[1];
|
|
528
3368
|
const cfgNow = await readGlobalConfig();
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
3369
|
+
const subcommand = roleArg?.toLowerCase();
|
|
3370
|
+
const printRoster = () => {
|
|
3371
|
+
console.log("Subagent roles (used by 'jeo team'):");
|
|
3372
|
+
for (const line of formatAgentsPanel(allSubagentRoles(cfgNow), r => ({
|
|
532
3373
|
model: resolveSubagentModel(r.id, cfgNow),
|
|
533
3374
|
maxSteps: resolveSubagentMaxSteps(r.id, cfgNow),
|
|
3375
|
+
thinking: resolveSubagentThinking(r.id, cfgNow),
|
|
534
3376
|
}))) console.log(line);
|
|
535
|
-
console.log("Detail: /agents <role> · set model: /agents <role> <model>");
|
|
3377
|
+
console.log("Detail: /agents <role> · set model: /agents <role> <model|#N> · provider: /agents <role> provider <name> [model] · thinking: /agents <role> thinking <level|inherit> · steps: /agents <role> maxSteps <N> · picker: /agents edit");
|
|
3378
|
+
console.log("Tip: primary model flow: /model → pick model → choose default or subagent role → choose thinking level");
|
|
3379
|
+
console.log(`Available: ${allSubagentRoles(cfgNow).map(r => r.id).join(", ")} (declare custom roles in config.subagents)`);
|
|
3380
|
+
console.log("Subcommands: edit, <role> <model|#N>, <role> thinking <level|inherit>, <role> provider <name> [model], <role> maxSteps <N>, <role> reset");
|
|
3381
|
+
};
|
|
3382
|
+
if (!roleArg || roleArg === "/" || roleArg === "?" || subcommand === "help") {
|
|
3383
|
+
printRoster();
|
|
536
3384
|
continue;
|
|
537
3385
|
}
|
|
538
|
-
|
|
3386
|
+
if (subcommand === "edit" || subcommand === "picker") {
|
|
3387
|
+
printRoster();
|
|
3388
|
+
// Interactive editor (TTY): role picker → action picker → live model /
|
|
3389
|
+
// thinking / reset — the arrows+Enter way to CHANGE an existing setting.
|
|
3390
|
+
const rolePick = await pickFromOptions(
|
|
3391
|
+
"Edit a subagent role (ESC to skip)",
|
|
3392
|
+
allSubagentRoles(cfgNow).map(r => ({
|
|
3393
|
+
value: r.id,
|
|
3394
|
+
label: `${r.id} — ${r.title}`,
|
|
3395
|
+
hint: `${resolveSubagentModel(r.id, cfgNow)} · ${resolveSubagentMaxSteps(r.id, cfgNow)} steps${cfgNow.subagents?.[r.id]?.model ? "" : " (default)"}`,
|
|
3396
|
+
})),
|
|
3397
|
+
);
|
|
3398
|
+
const editRole = rolePick ? getSubagentRole(rolePick, cfgNow) : undefined;
|
|
3399
|
+
if (!editRole) continue;
|
|
3400
|
+
const action = await pickFromOptions(`${editRole.title} — choose action`, [
|
|
3401
|
+
{ value: "model", label: "change model", hint: resolveSubagentModel(editRole.id, cfgNow) },
|
|
3402
|
+
{ value: "thinking", label: "change thinking", hint: resolveSubagentThinking(editRole.id, cfgNow) ?? `inherit (${cfgNow.thinkingLevel ?? "medium"})` },
|
|
3403
|
+
{ value: "reset", label: "reset to defaults", hint: "clears model + maxSteps + thinking override" },
|
|
3404
|
+
]);
|
|
3405
|
+
if (action === "reset") {
|
|
3406
|
+
await saveConfigPatch(raw => ({ subagents: clearSubagentSetting(raw, editRole.id) }));
|
|
3407
|
+
console.log(`${editRole.title} settings reset to defaults → ~/.jeo/config.json`);
|
|
3408
|
+
} else if (action === "model") {
|
|
3409
|
+
const live = await getLiveModels();
|
|
3410
|
+
const entries = flattenModels(live);
|
|
3411
|
+
const picked = await pickLiveProviderModel(`${editRole.id}`, entries, resolveSubagentModel(editRole.id, cfgNow));
|
|
3412
|
+
if (picked) {
|
|
3413
|
+
const pinned = qualifyModelId(picked.model, picked.provider);
|
|
3414
|
+
await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, editRole.id, { model: pinned }) }));
|
|
3415
|
+
console.log(`Subagent '${editRole.id}' model set to ${pinned} → ~/.jeo/config.json`);
|
|
3416
|
+
}
|
|
3417
|
+
} else if (action === "thinking") {
|
|
3418
|
+
const lvl = await pickThinkingLevel(
|
|
3419
|
+
`Reasoning for ${editRole.title}`,
|
|
3420
|
+
cfgNow.subagents?.[editRole.id]?.thinking,
|
|
3421
|
+
`inherit — follow default (${cfgNow.thinkingLevel ?? "medium"})`,
|
|
3422
|
+
);
|
|
3423
|
+
if (lvl) await setRoleThinking(editRole.id, lvl);
|
|
3424
|
+
}
|
|
3425
|
+
continue;
|
|
3426
|
+
}
|
|
3427
|
+
const role = getSubagentRole(roleArg, cfgNow);
|
|
539
3428
|
if (!role) {
|
|
540
|
-
console.log(`Unknown role '${roleArg}'. Known: ${
|
|
3429
|
+
console.log(`Unknown role '${roleArg}'. Known: ${allSubagentRoles(cfgNow).map(r => r.id).join(", ")}.`);
|
|
3430
|
+
continue;
|
|
3431
|
+
}
|
|
3432
|
+
if (modelArg?.toLowerCase() === "reset") {
|
|
3433
|
+
await saveConfigPatch(raw => ({ subagents: clearSubagentSetting(raw, role.id) }));
|
|
3434
|
+
console.log(`${role.title} settings reset to defaults → ~/.jeo/config.json`);
|
|
3435
|
+
continue;
|
|
3436
|
+
}
|
|
3437
|
+
if (modelArg?.toLowerCase() === "maxsteps" || modelArg?.toLowerCase() === "steps") {
|
|
3438
|
+
const maxSteps = parseMaxSteps(tokens[2]);
|
|
3439
|
+
if (!maxSteps) {
|
|
3440
|
+
console.log(`Usage: /agents ${role.id} maxSteps <positive-number>`);
|
|
3441
|
+
continue;
|
|
3442
|
+
}
|
|
3443
|
+
await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { maxSteps }) }));
|
|
3444
|
+
console.log(`${role.title} maxSteps set to ${maxSteps} → ~/.jeo/config.json`);
|
|
3445
|
+
continue;
|
|
3446
|
+
}
|
|
3447
|
+
if (modelArg?.toLowerCase() === "thinking" || modelArg?.toLowerCase() === "think") {
|
|
3448
|
+
await setRoleThinking(role.id, tokens[2]);
|
|
3449
|
+
continue;
|
|
3450
|
+
}
|
|
3451
|
+
if (modelArg?.toLowerCase() === "provider") {
|
|
3452
|
+
const want = (tokens[2] ?? "").toLowerCase();
|
|
3453
|
+
if (!isProviderName(want)) {
|
|
3454
|
+
console.log(`Usage: /agents ${role.id} provider <anthropic|openai|gemini|antigravity|ollama> [model|#N]`);
|
|
3455
|
+
continue;
|
|
3456
|
+
}
|
|
3457
|
+
const st = (await describeAllProviders()).find(s => s.name === want);
|
|
3458
|
+
if (st && !st.ready) {
|
|
3459
|
+
if (selectableThoughNotReady(st)) {
|
|
3460
|
+
console.log(notReadyWarning(st));
|
|
3461
|
+
} else {
|
|
3462
|
+
console.log(`Cannot pin ${role.title} to ${want}: not ready (${st.label}). Set ${st.envVar ?? "the provider key"} first.`);
|
|
3463
|
+
continue;
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
const live = await getLiveModels();
|
|
3467
|
+
const forProvider = flattenModels(live.filter(r => r.provider === want));
|
|
3468
|
+
const explicit = tokens[3];
|
|
3469
|
+
let chosenModel: string;
|
|
3470
|
+
if (explicit && forProvider.length) {
|
|
3471
|
+
const sel = resolveSelection(forProvider, explicit);
|
|
3472
|
+
if (sel.kind === "index" || sel.kind === "match") chosenModel = qualifyModelId(sel.entry.model, want);
|
|
3473
|
+
else if (sel.kind === "ambiguous") {
|
|
3474
|
+
console.log(`'${explicit}' matches ${sel.matches.length} ${want} models — be more specific:`);
|
|
3475
|
+
for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model}`);
|
|
3476
|
+
continue;
|
|
3477
|
+
} else if (sel.kind === "out-of-range") {
|
|
3478
|
+
console.log(`#${explicit.slice(1)} is out of range for ${want} (1-${sel.max}).`);
|
|
3479
|
+
continue;
|
|
3480
|
+
} else {
|
|
3481
|
+
chosenModel = qualifyModelId(explicit, want);
|
|
3482
|
+
}
|
|
3483
|
+
} else if (explicit) {
|
|
3484
|
+
chosenModel = qualifyModelId(explicit, want);
|
|
3485
|
+
} else if (forProvider.length) {
|
|
3486
|
+
// No model given → the provider's first live model, provider-qualified.
|
|
3487
|
+
chosenModel = qualifyModelId(forProvider[0]!.model, want);
|
|
3488
|
+
} else {
|
|
3489
|
+
chosenModel = PROVIDER_DEFAULT[want];
|
|
3490
|
+
}
|
|
3491
|
+
await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { model: chosenModel }) }));
|
|
3492
|
+
console.log(`${role.title} pinned to ${want} via model ${chosenModel} — saved to ~/.jeo/config.json`);
|
|
3493
|
+
if (forProvider.length) {
|
|
3494
|
+
lastPickIndex = forProvider;
|
|
3495
|
+
console.log(`Live ${want} models — refine with /agents ${role.id} #N:`);
|
|
3496
|
+
for (const line of formatPickListWithCapabilities(lastPickIndex, { current: chosenModel, cap: 12 })) console.log(line);
|
|
3497
|
+
}
|
|
541
3498
|
continue;
|
|
542
3499
|
}
|
|
543
3500
|
if (modelArg) {
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
3501
|
+
let chosenModel = modelArg;
|
|
3502
|
+
let entries = lastPickIndex;
|
|
3503
|
+
if (modelArg.startsWith("#") && entries.length === 0) {
|
|
3504
|
+
const live = await getLiveModels();
|
|
3505
|
+
entries = flattenModels(live);
|
|
3506
|
+
}
|
|
3507
|
+
if (entries.length) {
|
|
3508
|
+
const sel = resolveSelection(entries, modelArg);
|
|
3509
|
+
if (sel.kind === "index" || sel.kind === "match") {
|
|
3510
|
+
chosenModel = qualifyModelId(sel.entry.model, sel.entry.provider);
|
|
3511
|
+
const bad = (await describeAllProviders()).find(s => s.name === sel.entry.provider && !s.ready);
|
|
3512
|
+
if (bad) {
|
|
3513
|
+
if (selectableThoughNotReady(bad)) {
|
|
3514
|
+
console.log(notReadyWarning(bad));
|
|
3515
|
+
} else {
|
|
3516
|
+
console.log(`Cannot pin ${sel.entry.model}: ${sel.entry.provider} is not ready (${bad.label}). Set ${bad.envVar ?? "the provider key"} first.`);
|
|
3517
|
+
continue;
|
|
3518
|
+
}
|
|
3519
|
+
}
|
|
3520
|
+
} else if (sel.kind === "ambiguous") {
|
|
3521
|
+
console.log(`'${modelArg}' matches ${sel.matches.length} live models — be more specific:`);
|
|
3522
|
+
for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
|
|
3523
|
+
continue;
|
|
3524
|
+
} else if (sel.kind === "out-of-range") {
|
|
3525
|
+
console.log(`#${modelArg.slice(1)} is out of range (1-${sel.max}). Use /model or /provider <name> first.`);
|
|
3526
|
+
continue;
|
|
3527
|
+
}
|
|
3528
|
+
} else if (modelArg.startsWith("#")) {
|
|
3529
|
+
console.log("Use /model or /provider <name> first to build the numbered live model list.");
|
|
3530
|
+
continue;
|
|
3531
|
+
}
|
|
3532
|
+
// Persist a per-role model override to ~/.jeo/config.json (consumed by 'jeo team').
|
|
3533
|
+
await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { model: chosenModel }) }));
|
|
3534
|
+
const { provider } = await describeModel(chosenModel);
|
|
3535
|
+
console.log(`${role.title} model set to ${chosenModel} (${provider}) — saved to ~/.jeo/config.json`);
|
|
550
3536
|
const live = await getLiveModels();
|
|
551
|
-
if (!liveModelKnown(live,
|
|
552
|
-
console.log(` (note: '${
|
|
3537
|
+
if (!liveModelKnown(live, chosenModel)) {
|
|
3538
|
+
console.log(` (note: '${chosenModel}' is not in any live model list — verify it is valid for ${provider})`);
|
|
553
3539
|
}
|
|
554
3540
|
continue;
|
|
555
3541
|
}
|
|
556
3542
|
for (const line of formatAgentDetail(role, {
|
|
557
3543
|
model: resolveSubagentModel(role.id, cfgNow),
|
|
558
3544
|
maxSteps: resolveSubagentMaxSteps(role.id, cfgNow),
|
|
3545
|
+
thinking: resolveSubagentThinking(role.id, cfgNow),
|
|
559
3546
|
})) console.log(line);
|
|
3547
|
+
const live = await getLiveModels();
|
|
3548
|
+
const agentPick = flattenModels(live);
|
|
3549
|
+
if (agentPick.length) {
|
|
3550
|
+
lastPickIndex = agentPick;
|
|
3551
|
+
console.log(`Live models for ${role.title} — pin with /agents ${role.id} #N:`);
|
|
3552
|
+
for (const line of formatPickListWithCapabilities(lastPickIndex, { current: resolveSubagentModel(role.id, cfgNow), cap: 20 })) console.log(line);
|
|
3553
|
+
}
|
|
560
3554
|
continue;
|
|
561
3555
|
}
|
|
562
3556
|
if (input === "/config") {
|
|
@@ -582,9 +3576,35 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
582
3576
|
const TIERS = ["smol", "slow", "plan"] as const;
|
|
583
3577
|
if (tokens.length >= 2 && (TIERS as readonly string[]).includes(tokens[0])) {
|
|
584
3578
|
const tier = tokens[0] as (typeof TIERS)[number];
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
3579
|
+
let chosenModel = tokens[1]!;
|
|
3580
|
+
let entries = lastPickIndex;
|
|
3581
|
+
if (chosenModel.startsWith("#") && entries.length === 0) {
|
|
3582
|
+
const live = await getLiveModels();
|
|
3583
|
+
entries = flattenModels(live);
|
|
3584
|
+
}
|
|
3585
|
+
if (entries.length) {
|
|
3586
|
+
const sel = resolveSelection(entries, chosenModel);
|
|
3587
|
+
if (sel.kind === "index" || sel.kind === "match") {
|
|
3588
|
+
chosenModel = qualifyModelId(sel.entry.model, sel.entry.provider);
|
|
3589
|
+
const bad = (await describeAllProviders()).find(s => s.name === sel.entry.provider && !s.ready);
|
|
3590
|
+
if (bad) {
|
|
3591
|
+
console.log(`Cannot set role ${tier} to ${sel.entry.model}: ${sel.entry.provider} is not ready (${bad.label}). Set ${bad.envVar ?? "the provider key"} first.`);
|
|
3592
|
+
continue;
|
|
3593
|
+
}
|
|
3594
|
+
} else if (sel.kind === "ambiguous") {
|
|
3595
|
+
console.log(`'${chosenModel}' matches ${sel.matches.length} live models — be more specific:`);
|
|
3596
|
+
for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
|
|
3597
|
+
continue;
|
|
3598
|
+
} else if (sel.kind === "out-of-range") {
|
|
3599
|
+
console.log(`#${chosenModel.slice(1)} is out of range (1-${sel.max}). Use /model or /provider <name> first.`);
|
|
3600
|
+
continue;
|
|
3601
|
+
}
|
|
3602
|
+
} else if (chosenModel.startsWith("#")) {
|
|
3603
|
+
console.log("Use /model or /provider <name> first to build the numbered live model list.");
|
|
3604
|
+
continue;
|
|
3605
|
+
}
|
|
3606
|
+
await saveConfigPatch(raw => ({ roles: { ...(raw.roles ?? {}), [tier]: chosenModel } }));
|
|
3607
|
+
console.log(`Role '${tier}' model set to ${chosenModel} → ~/.jeo/config.json`);
|
|
588
3608
|
continue;
|
|
589
3609
|
}
|
|
590
3610
|
console.log("Model role tiers (fall back to the default model):");
|
|
@@ -593,6 +3613,43 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
593
3613
|
console.log(` ${tier.padEnd(5)} ${resolveRoleModel(tier, cfgNow)} (${provider})`);
|
|
594
3614
|
}
|
|
595
3615
|
console.log("Set a tier: /roles <smol|slow|plan> <model>");
|
|
3616
|
+
const live = await getLiveModels();
|
|
3617
|
+
const rolePick = flattenModels(live);
|
|
3618
|
+
if (rolePick.length) {
|
|
3619
|
+
lastPickIndex = rolePick;
|
|
3620
|
+
console.log("Live models for role tiers — set with /roles <tier> #N:");
|
|
3621
|
+
for (const line of formatPickListWithCapabilities(lastPickIndex, { cap: 15 })) console.log(line);
|
|
3622
|
+
}
|
|
3623
|
+
continue;
|
|
3624
|
+
}
|
|
3625
|
+
if (input.startsWith("/fast") && (input === "/fast" || input[5] === " ")) {
|
|
3626
|
+
const arg = input.substring(5).trim().toLowerCase() || "status";
|
|
3627
|
+
const cfgNow = await readGlobalConfig();
|
|
3628
|
+
const currentModel = sessionModel || cfgNow.defaultModel;
|
|
3629
|
+
const { resolved, provider } = await describeModel(currentModel);
|
|
3630
|
+
const fastLevel = fastThinkingLevelForModel(resolved);
|
|
3631
|
+
const currentThinking = sessionThinking ?? cfgNow.thinkingLevel ?? "medium";
|
|
3632
|
+
const status = fastLevel && currentThinking === fastLevel ? "on" : "off";
|
|
3633
|
+
if (arg === "status") {
|
|
3634
|
+
const support = fastLevel ? `supported (thinking ${fastLevel})` : "unsupported";
|
|
3635
|
+
console.log(`Fast mode: ${status} · ${support} · ${formatModelLine({ label: currentModel, resolved, provider })} · current thinking ${currentThinking}`);
|
|
3636
|
+
continue;
|
|
3637
|
+
}
|
|
3638
|
+
if (arg === "on") {
|
|
3639
|
+
if (!fastLevel) {
|
|
3640
|
+
console.log(`Fast mode is not advertised for ${formatModelLine({ label: currentModel, resolved, provider })}; pick a thinking-capable model with /model.`);
|
|
3641
|
+
continue;
|
|
3642
|
+
}
|
|
3643
|
+
sessionThinking = fastLevel;
|
|
3644
|
+
console.log(`Fast mode on: ${formatModelLine({ label: currentModel, resolved, provider })} · thinking ${fastLevel} (~${thinkingMaxTokens(fastLevel)} max tokens/step)`);
|
|
3645
|
+
continue;
|
|
3646
|
+
}
|
|
3647
|
+
if (arg === "off") {
|
|
3648
|
+
sessionThinking = cfgNow.thinkingLevel ?? "medium";
|
|
3649
|
+
console.log(`Fast mode off: restored thinking ${sessionThinking} (~${thinkingMaxTokens(sessionThinking)} max tokens/step)`);
|
|
3650
|
+
continue;
|
|
3651
|
+
}
|
|
3652
|
+
console.log("Usage: /fast [on|off|status]");
|
|
596
3653
|
continue;
|
|
597
3654
|
}
|
|
598
3655
|
if (input.startsWith("/thinking") && (input === "/thinking" || input[9] === " ")) {
|
|
@@ -613,44 +3670,203 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
613
3670
|
let arg = input.substring(6).trim();
|
|
614
3671
|
// `/model save [id]` → persist the (session or given) model as the config default.
|
|
615
3672
|
if (arg === "save" || arg.startsWith("save ")) {
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
3673
|
+
let toSave = arg.slice(4).trim();
|
|
3674
|
+
// Resolve `#N`/fuzzy through the same pick-list logic as `/model #N`, so we never
|
|
3675
|
+
// persist a literal token like "#2" as defaultModel (which then fails to route).
|
|
3676
|
+
if (toSave && lastPickIndex.length) {
|
|
3677
|
+
const sel = resolveSelection(lastPickIndex, toSave);
|
|
3678
|
+
if (sel.kind === "index" || sel.kind === "match") toSave = qualifyModelId(sel.entry.model, sel.entry.provider);
|
|
3679
|
+
else if (sel.kind === "ambiguous") {
|
|
3680
|
+
console.log(`'${toSave}' matches ${sel.matches.length} models — be more specific:`);
|
|
3681
|
+
for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
|
|
3682
|
+
continue;
|
|
3683
|
+
} else if (sel.kind === "out-of-range") {
|
|
3684
|
+
console.log(`#${toSave.slice(1)} is out of range (1-${sel.max}). Use /model or /provider <name> first.`);
|
|
3685
|
+
continue;
|
|
3686
|
+
}
|
|
3687
|
+
// kind "none" → treat `toSave` as a literal model id/alias.
|
|
3688
|
+
} else if (toSave.startsWith("#")) {
|
|
3689
|
+
console.log("Use /model or /provider <name> first to build the numbered list.");
|
|
3690
|
+
continue;
|
|
3691
|
+
}
|
|
3692
|
+
// Fall back to the FRESH on-disk default (not the stale session-start snapshot) so a
|
|
3693
|
+
// bare `/model save` after a prior `/model save <id>` never reverts the saved default.
|
|
3694
|
+
const finalSave = toSave || sessionModel || (await readGlobalConfig()).defaultModel;
|
|
3695
|
+
await saveConfigPatch(raw => rememberModelPatch(raw, finalSave));
|
|
3696
|
+
const { resolved, provider } = await describeModel(finalSave);
|
|
3697
|
+
console.log(`Default model saved: ${formatModelLine({ label: finalSave, resolved, provider })} → ~/.jeo/config.json`);
|
|
3698
|
+
continue;
|
|
3699
|
+
}
|
|
3700
|
+
const modelThinking = /^(?:thinking|think)(?:\s+(\S+))?$/i.exec(arg);
|
|
3701
|
+
if (modelThinking) {
|
|
3702
|
+
const level = (modelThinking[1] ?? "").toLowerCase();
|
|
3703
|
+
if (!isThinkingLevel(level)) {
|
|
3704
|
+
console.log("Usage: /model thinking <minimal|low|medium|high|xhigh>");
|
|
3705
|
+
continue;
|
|
3706
|
+
}
|
|
3707
|
+
sessionThinking = level;
|
|
3708
|
+
await saveConfigPatch(() => ({ thinkingLevel: level }));
|
|
3709
|
+
console.log(`Default thinking set to ${level} (~${thinkingMaxTokens(level)} max tokens/step) → ~/.jeo/config.json`);
|
|
3710
|
+
continue;
|
|
3711
|
+
}
|
|
3712
|
+
const statuses = await describeAllProviders();
|
|
3713
|
+
const disabledModelProviders = statuses.filter(s => !s.ready && !selectableThoughNotReady(s)).map(s => s.name);
|
|
3714
|
+
const roleMatch = /^(subagent|role)\s+(\S+)(?:\s+(.+))?$/i.exec(arg);
|
|
3715
|
+
if (roleMatch) {
|
|
3716
|
+
const role = getSubagentRole(roleMatch[2] ?? "", await readGlobalConfig());
|
|
3717
|
+
if (!role) {
|
|
3718
|
+
console.log("Usage: /model subagent <executor|planner|architect|critic> [model|#N]");
|
|
3719
|
+
continue;
|
|
3720
|
+
}
|
|
3721
|
+
let roleModelArg = (roleMatch[3] ?? "").trim();
|
|
3722
|
+
const roleThinking = /^(?:thinking|think)(?:\s+(\S+))?$/i.exec(roleModelArg);
|
|
3723
|
+
if (roleThinking) {
|
|
3724
|
+
await setRoleThinking(role.id, roleThinking[1]);
|
|
3725
|
+
continue;
|
|
3726
|
+
}
|
|
3727
|
+
let roleModelPickedFromSelector = false;
|
|
3728
|
+
if (!roleModelArg && process.stdin.isTTY && process.stdout.isTTY) {
|
|
3729
|
+
const live = await getLiveModels();
|
|
3730
|
+
lastPickIndex = flattenModels(live);
|
|
3731
|
+
if (lastPickIndex.length) {
|
|
3732
|
+
const currentResolved = (await describeModel(resolveSubagentModel(role.id, await readGlobalConfig()))).resolved;
|
|
3733
|
+
const picked = await pickLiveProviderModel(role.id, lastPickIndex, currentResolved, disabledModelProviders);
|
|
3734
|
+
if (!picked) {
|
|
3735
|
+
console.log("(cancelled)");
|
|
3736
|
+
continue;
|
|
3737
|
+
}
|
|
3738
|
+
roleModelArg = qualifyModelId(picked.model, picked.provider);
|
|
3739
|
+
roleModelPickedFromSelector = true;
|
|
3740
|
+
}
|
|
3741
|
+
}
|
|
3742
|
+
if (roleModelArg && lastPickIndex.length) {
|
|
3743
|
+
const sel = resolveSelection(lastPickIndex, roleModelArg);
|
|
3744
|
+
if (sel.kind === "index" || sel.kind === "match") {
|
|
3745
|
+
if (disabledModelProviders.includes(sel.entry.provider)) {
|
|
3746
|
+
const bad = statuses.find(s => s.name === sel.entry.provider);
|
|
3747
|
+
console.log(`Cannot select ${sel.entry.model}: ${sel.entry.provider} is not ready (${bad?.label ?? "not ready"}). Set ${bad?.envVar ?? "the provider key"} first.`);
|
|
3748
|
+
continue;
|
|
3749
|
+
}
|
|
3750
|
+
roleModelArg = qualifyModelId(sel.entry.model, sel.entry.provider);
|
|
3751
|
+
roleModelPickedFromSelector = true;
|
|
3752
|
+
} else if (sel.kind === "ambiguous") {
|
|
3753
|
+
console.log(`'${roleModelArg}' matches ${sel.matches.length} models — be more specific:`);
|
|
3754
|
+
for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
|
|
3755
|
+
continue;
|
|
3756
|
+
} else if (sel.kind === "out-of-range") {
|
|
3757
|
+
console.log(`#${roleModelArg.slice(1)} is out of range (1-${sel.max}). Use /model or /provider <name> first.`);
|
|
3758
|
+
continue;
|
|
3759
|
+
}
|
|
3760
|
+
} else if (roleModelArg.startsWith("#")) {
|
|
3761
|
+
console.log("Use /model or /provider <name> first to build the numbered list.");
|
|
3762
|
+
continue;
|
|
3763
|
+
}
|
|
3764
|
+
if (roleModelArg) {
|
|
3765
|
+
let thinkPatch: { thinking?: ThinkLevel } = {};
|
|
3766
|
+
if (roleModelPickedFromSelector && process.stdin.isTTY && process.stdout.isTTY) {
|
|
3767
|
+
const cfgForRole = await readGlobalConfig();
|
|
3768
|
+
const lvl = await pickThinkingLevel(
|
|
3769
|
+
`Reasoning for ${role.title}: ${roleModelArg}`,
|
|
3770
|
+
cfgForRole.subagents?.[role.id]?.thinking,
|
|
3771
|
+
`inherit — follow default (${cfgForRole.thinkingLevel ?? "medium"})`,
|
|
3772
|
+
);
|
|
3773
|
+
thinkPatch = lvl === "inherit" ? { thinking: undefined } : lvl ? { thinking: lvl } : {};
|
|
3774
|
+
}
|
|
3775
|
+
await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { model: roleModelArg, ...thinkPatch }) }));
|
|
3776
|
+
const { provider } = await describeModel(roleModelArg);
|
|
3777
|
+
const thinkNote = thinkPatch.thinking ? ` · thinking ${thinkPatch.thinking}` : "";
|
|
3778
|
+
console.log(`${role.title} model set to ${roleModelArg} (${provider})${thinkNote} — saved to ~/.jeo/config.json`);
|
|
3779
|
+
} else {
|
|
3780
|
+
const current = resolveSubagentModel(role.id, await readGlobalConfig());
|
|
3781
|
+
const { resolved, provider } = await describeModel(current);
|
|
3782
|
+
console.log(`${role.title} model: ${formatModelLine({ label: current, resolved, provider })}`);
|
|
3783
|
+
const live = await getLiveModels();
|
|
3784
|
+
lastPickIndex = flattenModels(live);
|
|
3785
|
+
if (lastPickIndex.length) {
|
|
3786
|
+
console.log(`Live models for ${role.title} — set with /model subagent ${role.id} #N:`);
|
|
3787
|
+
for (const line of formatPickListWithCapabilities(lastPickIndex, { current: resolved, cap: 20 })) console.log(line);
|
|
3788
|
+
}
|
|
3789
|
+
}
|
|
621
3790
|
continue;
|
|
622
3791
|
}
|
|
3792
|
+
let modelPickedFromSelector = false;
|
|
3793
|
+
if (!arg && process.stdin.isTTY && process.stdout.isTTY) {
|
|
3794
|
+
const live = await getLiveModels();
|
|
3795
|
+
lastPickIndex = flattenModels(live);
|
|
3796
|
+
if (lastPickIndex.length) {
|
|
3797
|
+
const currentResolved = (await describeModel(sessionModel || defaultModel)).resolved;
|
|
3798
|
+
const picked = await pickLiveProviderModel("live", lastPickIndex, currentResolved, disabledModelProviders);
|
|
3799
|
+
if (!picked) {
|
|
3800
|
+
console.log("(cancelled)");
|
|
3801
|
+
continue;
|
|
3802
|
+
}
|
|
3803
|
+
arg = qualifyModelId(picked.model, picked.provider);
|
|
3804
|
+
modelPickedFromSelector = true;
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
623
3807
|
// Selection from the last numbered pick list (`#N`) or a fuzzy substring.
|
|
624
3808
|
if (arg && lastPickIndex.length) {
|
|
625
3809
|
const sel = resolveSelection(lastPickIndex, arg);
|
|
626
3810
|
if (sel.kind === "index" || sel.kind === "match") {
|
|
627
|
-
|
|
3811
|
+
if (disabledModelProviders.includes(sel.entry.provider)) {
|
|
3812
|
+
const bad = statuses.find(s => s.name === sel.entry.provider);
|
|
3813
|
+
console.log(`Cannot select ${sel.entry.model}: ${sel.entry.provider} is not ready (${bad?.label ?? "not ready"}). Set ${bad?.envVar ?? "the provider key"} first.`);
|
|
3814
|
+
continue;
|
|
3815
|
+
}
|
|
3816
|
+
arg = qualifyModelId(sel.entry.model, sel.entry.provider);
|
|
3817
|
+
modelPickedFromSelector = true;
|
|
628
3818
|
} else if (sel.kind === "ambiguous") {
|
|
629
3819
|
console.log(`'${arg}' matches ${sel.matches.length} models — be more specific:`);
|
|
630
3820
|
for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
|
|
631
3821
|
continue;
|
|
632
3822
|
} else if (sel.kind === "out-of-range") {
|
|
633
|
-
console.log(`#${arg.slice(1)} is out of range (1-${sel.max}).
|
|
3823
|
+
console.log(`#${arg.slice(1)} is out of range (1-${sel.max}). Use /model or /provider <name> first.`);
|
|
634
3824
|
continue;
|
|
635
3825
|
}
|
|
636
3826
|
// kind "none" → fall through and treat `arg` as a literal model id/alias.
|
|
637
3827
|
} else if (arg.startsWith("#")) {
|
|
638
|
-
console.log("
|
|
3828
|
+
console.log("Use /model or /provider <name> first to build the numbered list.");
|
|
639
3829
|
continue;
|
|
640
3830
|
}
|
|
641
3831
|
const label = arg || (sessionModel || defaultModel);
|
|
642
|
-
if (arg
|
|
3832
|
+
if (arg && modelPickedFromSelector && await applyPickedModelWithTarget(arg)) {
|
|
3833
|
+
continue;
|
|
3834
|
+
}
|
|
3835
|
+
if (arg) {
|
|
3836
|
+
sessionModel = arg;
|
|
3837
|
+
// MRU persistence: picking a model IS saving it — the newest pick wins
|
|
3838
|
+
// as the global default; recents keep the rotation for every session.
|
|
3839
|
+
await saveConfigPatch(raw => rememberModelPatch(raw, arg));
|
|
3840
|
+
}
|
|
643
3841
|
const { resolved, provider } = await describeModel(label);
|
|
644
|
-
const statuses = await describeAllProviders();
|
|
645
3842
|
const st = statuses.find(s => s.name === provider);
|
|
646
|
-
console.log(`${arg ? "Model set to" : "Current model"}: ${formatModelLine({ label, resolved, provider, ready: st?.ready })}`);
|
|
647
|
-
if (st && !st.ready) console.log(` ! ${provider}
|
|
3843
|
+
console.log(`${arg ? "Model set to" : "Current model"}: ${formatModelLine({ label, resolved, provider, ready: st?.ready })}${arg ? " — saved as default" : ""}`);
|
|
3844
|
+
if (st && !st.ready) console.log(` ! ${provider} is not ready (${st.label}) — set ${st.envVar ?? "the provider key"} or run 'jeo setup'.`);
|
|
3845
|
+
// ChatGPT OAuth only serves the Codex models; warn before the turn fails if the user
|
|
3846
|
+
// pins a non-Codex id with no local base URL to fall back to (gjc-parity readiness guard).
|
|
3847
|
+
if (arg && provider === "openai" && st?.kind === "oauth" && !CODEX_MODELS.includes(resolved)) {
|
|
3848
|
+
const hasLocalBase = !!((await readGlobalConfig()).openaiBaseUrl || process.env.OPENAI_BASE_URL);
|
|
3849
|
+
if (!hasLocalBase) {
|
|
3850
|
+
console.log(` ! ChatGPT OAuth serves only Codex models (${CODEX_MODELS.join(", ")}); '${resolved}' will be rejected at runtime — pick one of those, or set OPENAI_API_KEY / OPENAI_BASE_URL.`);
|
|
3851
|
+
}
|
|
3852
|
+
}
|
|
648
3853
|
if (arg && liveModelsCache && resolved === label && !liveModelKnown(liveModelsCache, resolved)) {
|
|
649
|
-
console.log(` (note: '${resolved}' is not in the live ${provider} catalog —
|
|
3854
|
+
console.log(` (note: '${resolved}' is not in the live ${provider} catalog — use /model or /provider <name> to pick a valid id)`);
|
|
650
3855
|
}
|
|
651
3856
|
const meta = catalogMetadata(resolved);
|
|
652
3857
|
if (meta) console.log(` ${formatCapabilityLine(meta)}`);
|
|
653
|
-
|
|
3858
|
+
if (!arg) {
|
|
3859
|
+
const recents = recentModelsForDisplay(await readGlobalConfig());
|
|
3860
|
+
if (recents.length > 1) {
|
|
3861
|
+
console.log("Recent models (newest first):");
|
|
3862
|
+
recents.slice(0, 5).forEach((m, i) => console.log(` ${i + 1}. ${m}${i === 0 ? " ◀ default" : ""}`));
|
|
3863
|
+
}
|
|
3864
|
+
const live = await getLiveModels();
|
|
3865
|
+
lastPickIndex = flattenModels(live);
|
|
3866
|
+
console.log("Live models (logged-in providers) — set with /model #N:");
|
|
3867
|
+
for (const line of formatPickListWithCapabilities(lastPickIndex, { current: resolved, cap: 20 })) console.log(line);
|
|
3868
|
+
}
|
|
3869
|
+
console.log(" (model picks persist automatically — newest selection is the default everywhere)");
|
|
654
3870
|
continue;
|
|
655
3871
|
}
|
|
656
3872
|
if (input.startsWith("/view") && (input === "/view" || input[5] === " ")) {
|
|
@@ -675,7 +3891,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
675
3891
|
const lang = detectLanguage(file);
|
|
676
3892
|
const { lines, startLine } = sliceLines(content, range ?? undefined);
|
|
677
3893
|
const { cols } = await import("../tui/terminal").then(m => m.size());
|
|
678
|
-
console.log(chalk.bold(`${file}`)
|
|
3894
|
+
console.log(`${categoryBadge("file")} ${chalk.bold(`${file}`)}${chalk.gray(` (${languageLabel(lang)}, lines ${startLine}-${startLine + lines.length - 1})`)}`);
|
|
679
3895
|
for (const line of formatCodeBlock(lines.join("\n"), { startLine, lang, cols: Math.max(40, cols - 1), maxLines: 200 })) {
|
|
680
3896
|
console.log(line);
|
|
681
3897
|
}
|
|
@@ -694,7 +3910,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
694
3910
|
continue;
|
|
695
3911
|
}
|
|
696
3912
|
const { cols } = await import("../tui/terminal").then(m => m.size());
|
|
697
|
-
|
|
3913
|
+
console.log(`${categoryBadge("diff")} git diff${target ? ` -- ${target}` : ""}`);
|
|
3914
|
+
for (const line of formatDiff(text, { cols: Math.max(40, cols - 1), maxLines: 400, theme: uiTheme })) console.log(line);
|
|
698
3915
|
continue;
|
|
699
3916
|
}
|
|
700
3917
|
if (input.startsWith("/find") && (input === "/find" || input[5] === " ")) {
|
|
@@ -703,6 +3920,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
703
3920
|
console.log("Usage: /find <glob> (e.g. /find src/**/*.ts)");
|
|
704
3921
|
continue;
|
|
705
3922
|
}
|
|
3923
|
+
console.log(`${categoryBadge("search")} find files matching '${glob}':`);
|
|
706
3924
|
const res = await findTool(glob, cwd);
|
|
707
3925
|
console.log(res.success ? (res.output || "(no matches)") : `! ${res.error}`);
|
|
708
3926
|
continue;
|
|
@@ -715,31 +3933,122 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
715
3933
|
console.log("Usage: /search <pattern> [glob] (e.g. /search resolveProvider src/**/*.ts)");
|
|
716
3934
|
continue;
|
|
717
3935
|
}
|
|
3936
|
+
console.log(`${categoryBadge("search")} search pattern '${pattern}' in '${glob}':`);
|
|
718
3937
|
const res = await searchTool(pattern, glob, cwd);
|
|
719
3938
|
console.log(res.success ? (res.output || "(no matches)") : `! ${res.error}`);
|
|
720
3939
|
continue;
|
|
721
3940
|
}
|
|
722
|
-
|
|
3941
|
+
const skillEntrypoint = input.startsWith("/skill:") ? "/skill:" : input.startsWith("/skill") && (input === "/skill" || input[6] === " ") ? "/skill" : "";
|
|
3942
|
+
if (skillEntrypoint) {
|
|
3943
|
+
if (flags.noSkills) {
|
|
3944
|
+
console.log("Skills are disabled.");
|
|
3945
|
+
continue;
|
|
3946
|
+
}
|
|
3947
|
+
const rest = skillEntrypoint === "/skill:" ? input.substring(7).trim() : input.substring(6).trim();
|
|
3948
|
+
let skills = await loadSkills(cwd);
|
|
3949
|
+
if (flags.skills) {
|
|
3950
|
+
const patterns = flags.skills.split(",").map(p => p.trim()).filter(Boolean);
|
|
3951
|
+
skills = skills.filter(s => patterns.some(p => matchSkillGlob(p, s.name)));
|
|
3952
|
+
}
|
|
3953
|
+
if (!rest) {
|
|
3954
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
3955
|
+
const picked = await pickSkillFromList(skills);
|
|
3956
|
+
if (!picked) {
|
|
3957
|
+
console.log("(cancelled)");
|
|
3958
|
+
continue;
|
|
3959
|
+
}
|
|
3960
|
+
await runSkillInvocation(picked, "");
|
|
3961
|
+
continue;
|
|
3962
|
+
}
|
|
3963
|
+
console.log("Skills (bundled + configured docs) — run with /skill <name> [intent] or a skill slash alias:");
|
|
3964
|
+
for (const s of skills) {
|
|
3965
|
+
const aliases = skillSlashAliases(s);
|
|
3966
|
+
console.log(` ${s.name.padEnd(16)} ${s.summary}${aliases.length ? ` (${aliases.join(", ")})` : ""}`);
|
|
3967
|
+
}
|
|
3968
|
+
continue;
|
|
3969
|
+
}
|
|
3970
|
+
const [nm, ...intentParts] = rest.split(/\s+/);
|
|
3971
|
+
const skill = getSkillFrom(skills, nm);
|
|
3972
|
+
if (!skill) {
|
|
3973
|
+
console.log(`Unknown skill: ${nm}. Available: ${skills.map(s => s.name).join(", ")}`);
|
|
3974
|
+
continue;
|
|
3975
|
+
}
|
|
3976
|
+
const intent = intentParts.join(" ").trim();
|
|
3977
|
+
try {
|
|
3978
|
+
await runSkillInvocation(skill, intent);
|
|
3979
|
+
} catch (err) {
|
|
3980
|
+
console.log(`! ${(err as Error).message}`);
|
|
3981
|
+
}
|
|
3982
|
+
continue;
|
|
3983
|
+
}
|
|
3984
|
+
const aliasInvocation = parseSkillInvocation(input, resolvedSkills);
|
|
3985
|
+
if (aliasInvocation?.invokedAs) {
|
|
3986
|
+
try {
|
|
3987
|
+
await runSkillInvocation(aliasInvocation.skill, aliasInvocation.intent, aliasInvocation.invokedAs);
|
|
3988
|
+
} catch (err) {
|
|
3989
|
+
console.log(`! ${(err as Error).message}`);
|
|
3990
|
+
}
|
|
3991
|
+
continue;
|
|
3992
|
+
}
|
|
723
3993
|
// Unhandled slash attempt → suggest, don't send the typo to the model.
|
|
724
3994
|
if (isSlashAttempt(input)) {
|
|
725
|
-
const m = matchSlash(input);
|
|
726
|
-
|
|
3995
|
+
const m = matchSlash(input, [...completionContext().slashCommands]);
|
|
3996
|
+
if (m.length) {
|
|
3997
|
+
for (const line of formatSlashCommandList(input, skillSlashDetails)) console.log(line);
|
|
3998
|
+
} else {
|
|
3999
|
+
console.log(`Unknown command '${input}'. Try /help.`);
|
|
4000
|
+
}
|
|
727
4001
|
continue;
|
|
728
4002
|
}
|
|
729
4003
|
|
|
4004
|
+
lastUserInput = input;
|
|
4005
|
+
// Hand pending clipboard images to this turn and clear them — a failed turn
|
|
4006
|
+
// does not resurrect attachments (the [image #N] tags stay in the text).
|
|
4007
|
+
const turnImages = pendingImages.length ? [...pendingImages] : undefined;
|
|
4008
|
+
pendingImages = [];
|
|
730
4009
|
try {
|
|
731
|
-
const { done, steps, reply, rendered, usage } = await runTurn(input, useTui);
|
|
4010
|
+
const { done, steps, reply, rendered, usage } = await runTurn(input, useTui, turnImages);
|
|
4011
|
+
lastReply = reply;
|
|
4012
|
+
// A cancelled turn (ESC / Ctrl-C) must also cancel the pasted batch —
|
|
4013
|
+
// continuing to auto-run the rest of a paste after an abort is hostile.
|
|
4014
|
+
if (reply === "Cancelled." && queuedPromptInput.pastedLines.length > 0) {
|
|
4015
|
+
const dropped = queuedPromptInput.pastedLines.splice(0).length;
|
|
4016
|
+
console.log(chalk.dim(`(cancelled — dropped ${dropped} queued pasted command${dropped > 1 ? "s" : ""})`));
|
|
4017
|
+
}
|
|
732
4018
|
if (!rendered) {
|
|
733
|
-
console.log(`
|
|
4019
|
+
console.log(`jeo> ${renderMarkdownTables(reply)}${usage}`);
|
|
734
4020
|
if (!done) console.log(`(agent did not converge in ${steps} steps)`);
|
|
735
4021
|
} else if (usage) {
|
|
736
4022
|
console.log(usage.trim());
|
|
737
4023
|
}
|
|
738
4024
|
} catch (err) {
|
|
739
|
-
console.log(`! ${(err
|
|
4025
|
+
console.log(`! ${friendlyProviderError(err)}`);
|
|
740
4026
|
}
|
|
741
4027
|
}
|
|
742
|
-
|
|
743
|
-
|
|
4028
|
+
disarmPreview(); // clear footer + restore full-screen scrolling before leaving the REPL
|
|
4029
|
+
if (hardExitOnLoopEnd) {
|
|
4030
|
+
try {
|
|
4031
|
+
disarmPreview();
|
|
4032
|
+
out.write("\x1b[?25h\n");
|
|
4033
|
+
} catch { /* best effort */ }
|
|
4034
|
+
process.removeListener("SIGINT", forceExitFromCtrlC);
|
|
4035
|
+
process.stdin.off("data", forceExitOnCtrlCByte);
|
|
4036
|
+
restorePromptRawMode();
|
|
4037
|
+
process.exit(130);
|
|
4038
|
+
}
|
|
4039
|
+
// hermes-style experience distill (plan/gjc-inheritance.md B6) — now handed to a
|
|
4040
|
+
// DETACHED child (round-16): /exit and ^C^C return the shell IMMEDIATELY instead
|
|
4041
|
+
// of blocking up to 20s on a final LLM call. The child writes MEMORY.md atomically.
|
|
4042
|
+
if (sessionUsage.turns > 0) {
|
|
4043
|
+
const spawned = await spawnDetachedDistill(history, cwd, sessionModel || defaultModel);
|
|
4044
|
+
if (spawned) console.log(chalk.gray("(session memory distilling in background)"));
|
|
744
4045
|
}
|
|
4046
|
+
// gjc-parity resume pointer (logs/gjc-tui-study analysis Gap C): leave the exact
|
|
4047
|
+
// resume command in scrollback on exit, mirroring the --list handler's convention.
|
|
4048
|
+
if (sessionId && !flags.noSession) console.log(formatResumeHint(sessionId));
|
|
4049
|
+
process.removeListener("SIGINT", forceExitFromCtrlC);
|
|
4050
|
+
process.stdin.off("data", forceExitOnCtrlCByte);
|
|
4051
|
+
restorePromptRawMode();
|
|
4052
|
+
gracefulReadlineClose = true;
|
|
4053
|
+
rl.close();
|
|
745
4054
|
}
|