jeo-code 0.5.1 → 0.5.3

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 CHANGED
@@ -150,11 +150,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
150
150
  ## 変更履歴 (Changelog)
151
151
 
152
152
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
153
+ - **[0.5.3]** (2026-06-15) — `$` chains multiple skills in one line (all run, in order), plus multi-line prompt input — paste-merge and gated Shift+Enter.
154
+ - **[0.5.2]** (2026-06-14) — `$skill` prompt invocation with prefix/fuzzy suggestions, and a per-session input-box hue (amber in cmd-mode).
153
155
  - **[0.5.1]** (2026-06-14) — cmd-mode `!<command>` shell escape — run a shell command without engaging the agent.
154
156
  - **[0.5.0]** (2026-06-14) — Performance: workspace-scan, workflow-state, and DNA-Claw HUD caches; plus a credential-safety fix that never wipes OAuth over an invalid config.
155
157
  - **[0.4.9]** (2026-06-14) — Live-frame width-clamp (content-sized height) replaces the constant-height approach, typed text shows during a running turn, and a docs/AGENTS refresh.
156
- - **[0.4.8]** (2026-06-14) — Live-frame stability: constant-height live turn, renderer self-heal off-by-one fix, and frame-safe child-stdout sanitizing — no more duplicate model bar or torn escapes.
157
- - **[0.4.7]** (2026-06-14) — Detached subagents + `subagent` control tool, live shaded in-flight output, registry-driven providers, fuller `read` budget, styled italics in the final report, and `gjc` retired.
158
158
 
159
159
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
160
160
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -150,11 +150,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
150
150
  ## 변경 이력 (Changelog)
151
151
 
152
152
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
153
+ - **[0.5.3]** (2026-06-15) — `$` chains multiple skills in one line (all run, in order), plus multi-line prompt input — paste-merge and gated Shift+Enter.
154
+ - **[0.5.2]** (2026-06-14) — `$skill` prompt invocation with prefix/fuzzy suggestions, and a per-session input-box hue (amber in cmd-mode).
153
155
  - **[0.5.1]** (2026-06-14) — cmd-mode `!<command>` shell escape — run a shell command without engaging the agent.
154
156
  - **[0.5.0]** (2026-06-14) — Performance: workspace-scan, workflow-state, and DNA-Claw HUD caches; plus a credential-safety fix that never wipes OAuth over an invalid config.
155
157
  - **[0.4.9]** (2026-06-14) — Live-frame width-clamp (content-sized height) replaces the constant-height approach, typed text shows during a running turn, and a docs/AGENTS refresh.
156
- - **[0.4.8]** (2026-06-14) — Live-frame stability: constant-height live turn, renderer self-heal off-by-one fix, and frame-safe child-stdout sanitizing — no more duplicate model bar or torn escapes.
157
- - **[0.4.7]** (2026-06-14) — Detached subagents + `subagent` control tool, live shaded in-flight output, registry-driven providers, fuller `read` budget, styled italics in the final report, and `gjc` retired.
158
158
 
159
159
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
160
160
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -150,11 +150,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
150
150
  ## Changelog
151
151
 
152
152
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
153
+ - **[0.5.3]** (2026-06-15) — `$` chains multiple skills in one line (all run, in order), plus multi-line prompt input — paste-merge and gated Shift+Enter.
154
+ - **[0.5.2]** (2026-06-14) — `$skill` prompt invocation with prefix/fuzzy suggestions, and a per-session input-box hue (amber in cmd-mode).
153
155
  - **[0.5.1]** (2026-06-14) — cmd-mode `!<command>` shell escape — run a shell command without engaging the agent.
154
156
  - **[0.5.0]** (2026-06-14) — Performance: workspace-scan, workflow-state, and DNA-Claw HUD caches; plus a credential-safety fix that never wipes OAuth over an invalid config.
155
157
  - **[0.4.9]** (2026-06-14) — Live-frame width-clamp (content-sized height) replaces the constant-height approach, typed text shows during a running turn, and a docs/AGENTS refresh.
156
- - **[0.4.8]** (2026-06-14) — Live-frame stability: constant-height live turn, renderer self-heal off-by-one fix, and frame-safe child-stdout sanitizing — no more duplicate model bar or torn escapes.
157
- - **[0.4.7]** (2026-06-14) — Detached subagents + `subagent` control tool, live shaded in-flight output, registry-driven providers, fuller `read` budget, styled italics in the final report, and `gjc` retired.
158
158
 
159
159
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
160
160
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -150,11 +150,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
150
150
  ## 更新日志 (Changelog)
151
151
 
152
152
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
153
+ - **[0.5.3]** (2026-06-15) — `$` chains multiple skills in one line (all run, in order), plus multi-line prompt input — paste-merge and gated Shift+Enter.
154
+ - **[0.5.2]** (2026-06-14) — `$skill` prompt invocation with prefix/fuzzy suggestions, and a per-session input-box hue (amber in cmd-mode).
153
155
  - **[0.5.1]** (2026-06-14) — cmd-mode `!<command>` shell escape — run a shell command without engaging the agent.
154
156
  - **[0.5.0]** (2026-06-14) — Performance: workspace-scan, workflow-state, and DNA-Claw HUD caches; plus a credential-safety fix that never wipes OAuth over an invalid config.
155
157
  - **[0.4.9]** (2026-06-14) — Live-frame width-clamp (content-sized height) replaces the constant-height approach, typed text shows during a running turn, and a docs/AGENTS refresh.
156
- - **[0.4.8]** (2026-06-14) — Live-frame stability: constant-height live turn, renderer self-heal off-by-one fix, and frame-safe child-stdout sanitizing — no more duplicate model bar or torn escapes.
157
- - **[0.4.7]** (2026-06-14) — Detached subagents + `subagent` control tool, live shaded in-flight output, registry-driven providers, fuller `read` budget, styled italics in the final report, and `gjc` retired.
158
158
 
159
159
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
160
160
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -1,4 +1,6 @@
1
1
  import { createInterface } from "node:readline/promises";
2
+ import { emitKeypressEvents } from "node:readline";
3
+ import { PassThrough } from "node:stream";
2
4
  import { runAgentLoop, executorSystemPrompt, DEFAULT_TOOLS, TOOL_PROTOCOL, WORKING_DISCIPLINE, type AgentLoopEvents } from "../agent/engine";
3
5
  import { initialDynamicStepLimit } from "../agent/step-budget";
4
6
  import { memoryPromptSection, spawnDetachedDistill } from "../agent/memory";
@@ -11,7 +13,7 @@ import { runDeepInterviewEngine } from "./deep-interview";
11
13
  import { runRalplanEngine } from "./ralplan";
12
14
  import { runTeamEngine } from "./team";
13
15
  import { runUltragoalEngine } from "./ultragoal";
14
- import { skillsPromptSection, loadSkills, formatSkill, buildSkillTask, getSkillFrom, skillSlashAliases, workflowSkillsForPrompt, parseSkillInvocation, looksLikeSkillEcho, skillInvocationCard, type SkillDoc } from "../skills/catalog";
16
+ import { skillsPromptSection, loadSkills, formatSkill, buildSkillTask, getSkillFrom, skillSlashAliases, workflowSkillsForPrompt, parseSkillInvocation, parseSkillChain, looksLikeSkillEcho, skillInvocationCard, type SkillDoc, type SkillInvocation } from "../skills/catalog";
15
17
  import { formatForgeBox } from "../tui/components/forge";
16
18
  import { interactiveOAuthLogin } from "./auth";
17
19
  import { logoutOAuth } from "../auth";
@@ -1594,13 +1596,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1594
1596
  return;
1595
1597
  }
1596
1598
  }
1597
- const skillInvocation = parseSkillInvocation(messageContent, resolvedSkills);
1598
- if (skillInvocation) {
1599
- const isBundleWorkflow = ["deep-interview", "ralplan", "team", "ultragoal"].includes(skillInvocation.skill.name);
1599
+ // One skill run (bundle workflow → engine; regular skill → agent turn). Shared by the
1600
+ // single-invocation path and the `$a $b …` chain path so every `$` skill actually runs.
1601
+ const runOneSkillShot = async (inv: SkillInvocation): Promise<void> => {
1602
+ const isBundleWorkflow = ["deep-interview", "ralplan", "team", "ultragoal"].includes(inv.skill.name);
1600
1603
  if (isBundleWorkflow) {
1601
1604
  const startMsg: Message = {
1602
1605
  role: "system",
1603
- content: `[workflow:${skillInvocation.skill.name}:start]${skillInvocation.intent ? ` intent: ${skillInvocation.intent}` : ""}`
1606
+ content: `[workflow:${inv.skill.name}:start]${inv.intent ? ` intent: ${inv.intent}` : ""}`
1604
1607
  };
1605
1608
  history.push(startMsg);
1606
1609
  if (sessionId) {
@@ -1623,18 +1626,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1623
1626
  console.log(line);
1624
1627
  }
1625
1628
  },
1626
- args: skillInvocation.skill.name === "deep-interview" ? (skillInvocation.intent ? skillInvocation.intent.split(/\s+/) : []) : undefined
1629
+ args: inv.skill.name === "deep-interview" ? (inv.intent ? inv.intent.split(/\s+/) : []) : undefined
1627
1630
  };
1628
1631
 
1629
1632
  let ok = false;
1630
1633
  let reason: string | undefined;
1631
1634
  try {
1632
1635
  let res: { ok: boolean; reason?: string };
1633
- if (skillInvocation.skill.name === "deep-interview") {
1636
+ if (inv.skill.name === "deep-interview") {
1634
1637
  res = await runDeepInterviewEngine(opts);
1635
- } else if (skillInvocation.skill.name === "ralplan") {
1638
+ } else if (inv.skill.name === "ralplan") {
1636
1639
  res = await runRalplanEngine(opts);
1637
- } else if (skillInvocation.skill.name === "team") {
1640
+ } else if (inv.skill.name === "team") {
1638
1641
  res = await runTeamEngine(opts);
1639
1642
  } else {
1640
1643
  res = await runUltragoalEngine(opts);
@@ -1650,9 +1653,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1650
1653
 
1651
1654
  const endMsg: Message = {
1652
1655
  role: "system",
1653
- content: ok
1654
- ? `[workflow:${skillInvocation.skill.name}:finish]`
1655
- : `[workflow:${skillInvocation.skill.name}:abort]${reason ? ` reason: ${reason}` : ""}`
1656
+ content: ok
1657
+ ? `[workflow:${inv.skill.name}:finish]`
1658
+ : `[workflow:${inv.skill.name}:abort]${reason ? ` reason: ${reason}` : ""}`
1656
1659
  };
1657
1660
  if (sessionId) {
1658
1661
  await appendMessage(sessionId, endMsg, cwd);
@@ -1662,12 +1665,32 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1662
1665
 
1663
1666
  const useOneShotTui = shouldUseOneShotTui(flags.noTui);
1664
1667
  if (!useOneShotTui) {
1665
- console.log(`▶ Running skill: ${skillInvocation.skill.name}${skillInvocation.intent ? ` — ${skillInvocation.intent}` : ""}`);
1668
+ console.log(`▶ Running skill: ${inv.skill.name}${inv.intent ? ` — ${inv.intent}` : ""}`);
1666
1669
  }
1667
- const task = buildSkillTask(skillInvocation.skill, skillInvocation.intent, skillInvocation.invokedAs);
1670
+ const task = buildSkillTask(inv.skill, inv.intent, inv.invokedAs);
1668
1671
  const { reply, rendered, usage } = await runTurn(task, useOneShotTui);
1669
1672
  if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
1670
1673
  else if (usage) console.log(usage.trim());
1674
+ };
1675
+
1676
+ // `$a $b … [intent]` — run every resolved skill in order; a lone `$skill` is a chain of 1.
1677
+ const skillChain = parseSkillChain(messageContent, resolvedSkills);
1678
+ if (skillChain && skillChain.invocations.length) {
1679
+ if (skillChain.unresolved.length) {
1680
+ console.log(`(skipping unknown skill${skillChain.unresolved.length > 1 ? "s" : ""}: ${skillChain.unresolved.map(u => `$${u}`).join(", ")})`);
1681
+ }
1682
+ if (skillChain.invocations.length > 1) {
1683
+ console.log(`▶ Chaining ${skillChain.invocations.length} skills: ${skillChain.invocations.map(i => `$${i.skill.name}`).join(" → ")}`);
1684
+ }
1685
+ for (const inv of skillChain.invocations) {
1686
+ await runOneSkillShot(inv);
1687
+ }
1688
+ return;
1689
+ }
1690
+ // Slash / file-path / `/skill:` entrypoints (non-`$`) still resolve to a single skill.
1691
+ const skillInvocation = parseSkillInvocation(messageContent, resolvedSkills);
1692
+ if (skillInvocation) {
1693
+ await runOneSkillShot(skillInvocation);
1671
1694
  return;
1672
1695
  }
1673
1696
  try {
@@ -1891,8 +1914,50 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1891
1914
  // scrollback before the boxed footer takes over.
1892
1915
  let promptActive = false;
1893
1916
  let pickerActive = false;
1917
+ // ── Multi-line input (JEO_MULTILINE=1, gated) ──────────────────────────────────
1918
+ // Shift+Enter inserts a newline instead of submitting. ghostty (and most terminals)
1919
+ // send Shift+Enter as a distinct escape sequence (\x1b[27;2;13~ legacy, \x1b[13;2u
1920
+ // kitty) that node:readline otherwise mangles into garbage. We route stdin through a
1921
+ // filter that rewrites those sequences to a private-use SENTINEL char BEFORE readline
1922
+ // sees them — readline inserts it as an ordinary character (no submit), and we render
1923
+ // it as a real line break and expand it to "\n" on submit. Default OFF: when disabled
1924
+ // the prompt reads process.stdin directly, exactly as before (zero risk).
1925
+ const SENTINEL = "\uE000";
1926
+ const SHIFT_ENTER_SEQS = ["\u001b[27;2;13~", "\u001b[13;2u"];
1927
+ const multilineInput = jeoEnv("MULTILINE") === "1" && !!process.stdin.isTTY;
1928
+ const expandSentinel = (s: string): string => (multilineInput ? s.split(SENTINEL).join("\n") : s);
1929
+ let keyFilter: PassThrough | undefined;
1930
+ if (multilineInput) {
1931
+ const kf = new PassThrough();
1932
+ (kf as unknown as { isTTY: boolean }).isTTY = true;
1933
+ (kf as unknown as { setRawMode: (m: boolean) => unknown }).setRawMode = (m: boolean) => {
1934
+ try { (process.stdin as { setRawMode?(r: boolean): void }).setRawMode?.(m); } catch { /* terminal gone */ }
1935
+ return kf;
1936
+ };
1937
+ Object.defineProperty(kf, "isRaw", { get: () => (process.stdin as { isRaw?: boolean }).isRaw });
1938
+ // Forward stdin → filter, rewriting Shift+Enter into the newline sentinel BEFORE
1939
+ // readline sees it. Three encodings are handled so it works across setups:
1940
+ // • a lone "\n" (0x0a) chunk — what ghostty's `keybind = shift+enter=text:\n`
1941
+ // sends; a normal byte that passes through tmux UNCHANGED (works even with
1942
+ // tmux extended-keys off). Enter sends "\r" in raw mode, so a lone "\n" is
1943
+ // unambiguously Shift+Enter. This is the reliable ghostty+tmux path.
1944
+ // • the xterm legacy "\x1b[27;2;13~" and kitty "\x1b[13;2u" sequences — direct
1945
+ // terminal (no tmux) or tmux with extended-keys on. (Sent atomically, so a
1946
+ // per-chunk replace suffices; no partial buffering that could swallow ESC.)
1947
+ process.stdin.on("data", (chunk: Buffer) => {
1948
+ let data = chunk.toString("utf8");
1949
+ if (data === "\n") data = SENTINEL;
1950
+ else for (const seq of SHIFT_ENTER_SEQS) if (data.includes(seq)) data = data.split(seq).join(SENTINEL);
1951
+ kf.write(data);
1952
+ });
1953
+ keyFilter = kf;
1954
+ // readline now decodes keypresses on `keyFilter`; keep process.stdin emitting
1955
+ // 'keypress' too so the footer-redraw / paste-marker / picker listeners (registered
1956
+ // on process.stdin below) still fire.
1957
+ emitKeypressEvents(process.stdin);
1958
+ }
1894
1959
  const rl = createInterface({
1895
- input: process.stdin,
1960
+ input: keyFilter ?? process.stdin,
1896
1961
  // Single-box input: gate readline's output while the boxed footer is armed so its own
1897
1962
  // `jeo>` prompt/echo is suppressed and ONLY our box shows. (Bun exposes no
1898
1963
  // `_writeToOutput` to patch, so gating the shared output stream is the portable fix.)
@@ -1937,10 +2002,19 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1937
2002
  // outside it keep the existing contracts (typed-line prefill fold on a TTY,
1938
2003
  // in-order auto-serve for piped stdin).
1939
2004
  let promptPasteActive = false;
2005
+ // PASTE-MERGE: a multi-line bracketed paste must arrive as ONE message, not split into
2006
+ // one command per line. Lines that fire WHILE a paste is open are buffered here instead
2007
+ // of run individually; promptInput joins them with the trailing residual on resolve.
2008
+ // `endWaiters` wake that merge the moment the 201~ paste-end marker arrives.
2009
+ const pasteMerge: { buf: string[]; endWaiters: Array<() => void> } = { buf: [], endWaiters: [] };
2010
+ let pasteLineFired = false; // the line that resolved rl.question came from inside a paste
1940
2011
  if (process.stdin.isTTY) {
1941
2012
  process.stdin.on("keypress", (_ch: string, key: { name?: string } | undefined) => {
1942
- if (key?.name === "paste-start") promptPasteActive = true;
1943
- else if (key?.name === "paste-end") promptPasteActive = false;
2013
+ if (key?.name === "paste-start") { promptPasteActive = true; pasteMerge.buf = []; }
2014
+ else if (key?.name === "paste-end") {
2015
+ promptPasteActive = false;
2016
+ for (const w of pasteMerge.endWaiters.splice(0)) w();
2017
+ }
1944
2018
  });
1945
2019
  // Enable bracketed paste for the REPL lifetime (restored on exit below):
1946
2020
  // terminals only wrap pastes in the 200~/201~ markers once the app opts in.
@@ -1949,7 +2023,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1949
2023
  }
1950
2024
  rl.on("line", l => {
1951
2025
  if (promptPasteActive) {
1952
- queuedPromptInput.pastedLines.push(l);
2026
+ // Inside a bracketed paste: buffer the line, never run it on its own.
2027
+ pasteMerge.buf.push(l);
2028
+ pasteLineFired = true;
1953
2029
  return;
1954
2030
  }
1955
2031
  if (!interactiveTurnActive) pendingStdinLines.push(l);
@@ -1989,8 +2065,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1989
2065
  return "/exit";
1990
2066
  }
1991
2067
  promptActive = true;
2068
+ pasteLineFired = false;
1992
2069
  try {
1993
- return await Promise.race([
2070
+ const value = await Promise.race([
1994
2071
  rl.question(prompt),
1995
2072
  new Promise<string>(resolve => {
1996
2073
  notifyStdinClosed = () => {
@@ -1999,6 +2076,29 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1999
2076
  };
2000
2077
  }),
2001
2078
  ]);
2079
+ // PASTE-MERGE: if the resolving line came from inside a bracketed paste, the rest of
2080
+ // the paste (buffered in pasteMerge.buf) plus the trailing residual still in the line
2081
+ // buffer belong to the SAME message. Wait for paste-end, then join them with newlines
2082
+ // and return ONE multi-line input instead of running line 1 and queuing the rest.
2083
+ if (pasteLineFired) {
2084
+ if (promptPasteActive) {
2085
+ await new Promise<void>(resolve => {
2086
+ if (!promptPasteActive) { resolve(); return; }
2087
+ pasteMerge.endWaiters.push(resolve);
2088
+ setTimeout(resolve, 250); // safety: never hang if the 201~ marker is dropped
2089
+ });
2090
+ }
2091
+ const residual = (rl as unknown as { line?: string }).line ?? "";
2092
+ // Clear the residual so it does NOT prefill (and re-submit) the next prompt.
2093
+ try { rl.write(null, { ctrl: true, name: "u" }); } catch { /* best-effort */ }
2094
+ try { (rl as unknown as { line: string; cursor: number }).line = ""; (rl as unknown as { cursor: number }).cursor = 0; } catch { /* best-effort */ }
2095
+ const parts = [...pasteMerge.buf];
2096
+ if (residual !== "") parts.push(residual);
2097
+ pasteMerge.buf = [];
2098
+ pasteLineFired = false;
2099
+ return expandSentinel(parts.join("\n"));
2100
+ }
2101
+ return expandSentinel(value);
2002
2102
  } finally {
2003
2103
  promptActive = false;
2004
2104
  notifyStdinClosed = undefined;
@@ -2148,6 +2248,27 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2148
2248
  let uiTheme = resolveTheme(process.env);
2149
2249
  let uiAccent = accentPaint(uiTheme);
2150
2250
  let uiAccentShadow = accentShadowPaint(uiTheme);
2251
+ // Input-box border colors. Each opened session gets a DISTINCT hue (so several jeo
2252
+ // sessions are tellable apart at a glance), and cmd-mode (`!`) overrides it with a
2253
+ // caution amber so entering the shell escape is unmistakable.
2254
+ const SESSION_BOX_ACCENTS = ["#48dbfb", "#39ff14", "#a29bfe", "#1dd1a1", "#ff9ff3", "#54a0ff", "#ff6b81", "#c8d6e5"];
2255
+ const CMD_MODE_BOX_ACCENT = "#ffb300";
2256
+ const hexPaint = (hex: string) => (s: string) => chalk.hex(hex)(s);
2257
+ const hexShadowPaint = (hex: string) => (s: string) => chalk.dim(chalk.hex(hex)(s));
2258
+ // Per-process random start so different jeo processes differ at a glance; advanced on
2259
+ // each newly opened session (advanceSessionBoxColor) so consecutive sessions never match.
2260
+ let sessionBoxColorIdx = Math.floor(Math.random() * SESSION_BOX_ACCENTS.length);
2261
+ const advanceSessionBoxColor = (): void => {
2262
+ sessionBoxColorIdx = (sessionBoxColorIdx + 1) % SESSION_BOX_ACCENTS.length;
2263
+ };
2264
+ // Resolve the box painters for the current draft: cmd-mode amber when it starts with
2265
+ // `!`, else the per-session hue, else the theme accent (colorless theme / no session).
2266
+ const boxAccents = (line: string): { accent: (s: string) => string; shadow: (s: string) => string } => {
2267
+ if (!uiTheme.color) return { accent: uiAccent, shadow: uiAccentShadow };
2268
+ if (line.startsWith("!")) return { accent: hexPaint(CMD_MODE_BOX_ACCENT), shadow: hexShadowPaint(CMD_MODE_BOX_ACCENT) };
2269
+ if (sessionId) { const hex = SESSION_BOX_ACCENTS[sessionBoxColorIdx]!; return { accent: hexPaint(hex), shadow: hexShadowPaint(hex) }; }
2270
+ return { accent: uiAccent, shadow: uiAccentShadow };
2271
+ };
2151
2272
  const refreshUiTheme = (): void => {
2152
2273
  uiTheme = resolveTheme(process.env);
2153
2274
  uiAccent = accentPaint(uiTheme);
@@ -2182,12 +2303,13 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2182
2303
  // sits at the end of the text.
2183
2304
  const rli = rl as unknown as { line?: string; cursor?: number };
2184
2305
  const caret = rli.line === line && typeof rli.cursor === "number" ? rli.cursor : line.length;
2185
- const frame = renderInputFrame(line, {
2306
+ const { accent: boxAccent, shadow: boxShadow } = boxAccents(line);
2307
+ const frame = renderInputFrame(expandSentinel(line), {
2186
2308
  cols,
2187
2309
  color: true,
2188
2310
  unicode: true,
2189
- accent: uiAccent,
2190
- accentShadow: uiAccentShadow,
2311
+ accent: boxAccent,
2312
+ accentShadow: boxShadow,
2191
2313
  cwdLabel: currentAtLabel(line),
2192
2314
  attachmentLabel: pendingImages.length
2193
2315
  ? `⧉ ${pendingImages.length} image${pendingImages.length > 1 ? "s" : ""} attached — sent with the next message`
@@ -3062,6 +3184,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3062
3184
  history.length = 1;
3063
3185
  if (!flags.noSession) {
3064
3186
  sessionId = (await createSession(cwd)).id;
3187
+ advanceSessionBoxColor(); // distinct input-box hue per newly opened session
3065
3188
  console.log(`(${verb} — new session ${sessionId})`);
3066
3189
  } else {
3067
3190
  sessionId = undefined;
@@ -4106,6 +4229,25 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4106
4229
  }
4107
4230
  continue;
4108
4231
  }
4232
+ // `$a $b ... [intent]` — run EVERY resolved skill in the leading run, in order, each
4233
+ // with the shared trailing intent. A lone `$skill` is just a chain of length 1.
4234
+ const dollarChain = input.startsWith("$") ? parseSkillChain(input, resolvedSkills) : null;
4235
+ if (dollarChain && dollarChain.invocations.length) {
4236
+ if (dollarChain.unresolved.length) {
4237
+ console.log(`(skipping unknown skill${dollarChain.unresolved.length > 1 ? "s" : ""}: ${dollarChain.unresolved.map(u => `$${u}`).join(", ")})`);
4238
+ }
4239
+ if (dollarChain.invocations.length > 1) {
4240
+ console.log(`▶ Chaining ${dollarChain.invocations.length} skills: ${dollarChain.invocations.map(i => `$${i.skill.name}`).join(" → ")}`);
4241
+ }
4242
+ for (const inv of dollarChain.invocations) {
4243
+ try {
4244
+ await runSkillInvocation(inv.skill, inv.intent, inv.invokedAs);
4245
+ } catch (err) {
4246
+ console.log(`! ${(err as Error).message}`);
4247
+ }
4248
+ }
4249
+ continue;
4250
+ }
4109
4251
  const aliasInvocation = parseSkillInvocation(input, resolvedSkills);
4110
4252
  if (aliasInvocation?.invokedAs) {
4111
4253
  try {
@@ -4115,6 +4257,33 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4115
4257
  }
4116
4258
  continue;
4117
4259
  }
4260
+ // Unresolved `$skill` → suggest precisely, never silently send the typo to the model.
4261
+ // `$exact`/`$prefix`/chains already ran above; a leftover `$word` is a missed skill
4262
+ // attempt, EXCEPT `$UPPERCASE` env-var-style tokens (e.g. `$HOME`) which pass through.
4263
+ if (input.startsWith("$")) {
4264
+ const unresolved = (dollarChain?.unresolved.length
4265
+ ? dollarChain.unresolved
4266
+ : [(input.split(/\s+/, 1)[0] ?? "").slice(1)]
4267
+ ).filter(t => t && !/^[A-Z_][A-Z0-9_]*$/.test(t));
4268
+ if (unresolved.length > 1) {
4269
+ console.log(`No skills: ${unresolved.map(u => `$${u}`).join(", ")}. (Type $ to autocomplete.)`);
4270
+ continue;
4271
+ }
4272
+ if (unresolved.length === 1) {
4273
+ const token = unresolved[0]!;
4274
+ const lc = token.toLowerCase();
4275
+ const prefix = resolvedSkills.filter(s => s.name.toLowerCase().startsWith(lc));
4276
+ if (prefix.length) {
4277
+ console.log(`Ambiguous skill '$${token}'. Did you mean: ${prefix.slice(0, 6).map(s => `$${s.name}`).join(", ")}?`);
4278
+ } else {
4279
+ const names = resolvedSkills.map(s => `$${s.name}`);
4280
+ const shown = names.slice(0, 12).join(", ");
4281
+ const more = names.length > 12 ? ` … +${names.length - 12} more` : "";
4282
+ console.log(`No skill '$${token}'. ${names.length ? `Available: ${shown}${more}` : "No skills are loaded."} (Type $ to autocomplete.)`);
4283
+ }
4284
+ continue;
4285
+ }
4286
+ }
4118
4287
  // Unhandled slash attempt → suggest, don't send the typo to the model.
4119
4288
  if (isSlashAttempt(input)) {
4120
4289
  const m = matchSlash(input, [...completionContext().slashCommands]);
@@ -475,6 +475,32 @@ export function getSkillFrom(skills: SkillDoc[], name: string): SkillDoc | undef
475
475
  return skills.find(s => s.name.toLowerCase() === name.toLowerCase());
476
476
  }
477
477
 
478
+ /** The single skill whose name PREFIX-matches `query` (case-insensitive), or undefined
479
+ * when zero or many match. Lets `$te` precisely resolve to `$team` without full spelling. */
480
+ export function uniquePrefixSkill(skills: SkillDoc[], query: string): SkillDoc | undefined {
481
+ const q = query.toLowerCase();
482
+ if (!q) return undefined;
483
+ const hits = skills.filter(s => s.name.toLowerCase().startsWith(q));
484
+ return hits.length === 1 ? hits[0] : undefined;
485
+ }
486
+
487
+ /** Prefix-first, then fuzzy-subsequence skill suggestions for a `$query` that did NOT
488
+ * resolve — drives the REPL's clear "did you mean / available" feedback. */
489
+ export function suggestSkills(skills: SkillDoc[], query: string): SkillDoc[] {
490
+ const q = query.toLowerCase();
491
+ const prefix = skills.filter(s => s.name.toLowerCase().startsWith(q));
492
+ const seen = new Set(prefix.map(s => s.name));
493
+ const fuzzy = skills.filter(s => !seen.has(s.name) && skillNameSubsequence(q, s.name.toLowerCase()));
494
+ return [...prefix, ...fuzzy];
495
+ }
496
+
497
+ /** Order-preserving subsequence test (every char of `needle` appears in `hay` L→R). */
498
+ function skillNameSubsequence(needle: string, hay: string): boolean {
499
+ let i = 0;
500
+ for (let j = 0; j < hay.length && i < needle.length; j++) if (hay[j] === needle[i]) i++;
501
+ return i === needle.length;
502
+ }
503
+
478
504
  /** Case-insensitive lookup by direct slash alias, e.g. `/speckit.plan`. */
479
505
  export function getSkillBySlash(skills: SkillDoc[], command: string): SkillDoc | undefined {
480
506
  const q = command.toLowerCase();
@@ -514,7 +540,7 @@ export function parseSkillInvocation(input: string, skills: SkillDoc[]): SkillIn
514
540
  // only when a skill with that exact name is loaded — `$HOME is what?` or any
515
541
  // unknown `$word` falls through to the model as an ordinary prompt.
516
542
  if (command.length > 1 && command.startsWith("$")) {
517
- const dollarSkill = getSkillFrom(skills, command.slice(1));
543
+ const dollarSkill = getSkillFrom(skills, command.slice(1)) ?? uniquePrefixSkill(skills, command.slice(1));
518
544
  if (dollarSkill) {
519
545
  return { skill: dollarSkill, intent: trimmed.slice(command.length).trim(), invokedAs: command };
520
546
  }
@@ -530,6 +556,38 @@ export function parseSkillInvocation(input: string, skills: SkillDoc[]): SkillIn
530
556
  }
531
557
  return skill ? { skill, intent: trimmed.slice(command.length).trim(), invokedAs: command } : null;
532
558
  }
559
+
560
+ /** Parse a LEADING run of `$skill` tokens into an ordered chain that shares the trailing
561
+ * text as one intent: `$ralplan $team build auth` → [ralplan, team] each with intent
562
+ * "build auth". This is what lets `$` invoke several skills in one line — they all run,
563
+ * in order. Scanning stops at the first non-`$` token, OR a `$UPPERCASE` env-var-style
564
+ * token (e.g. `$HOME`), which is left in the intent so shell-style references pass through.
565
+ * Each `$name` resolves by exact name then unique prefix; names that resolve to nothing go
566
+ * into `unresolved` (so the REPL can report every typo, not just the first). Returns null
567
+ * only when the input opens with no parseable `$skill` token at all. */
568
+ export function parseSkillChain(
569
+ input: string,
570
+ skills: SkillDoc[],
571
+ ): { invocations: SkillInvocation[]; unresolved: string[] } | null {
572
+ const trimmed = input.trim();
573
+ if (!trimmed.startsWith("$")) return null;
574
+ const tokens = trimmed.split(/\s+/);
575
+ const invocations: SkillInvocation[] = [];
576
+ const unresolved: string[] = [];
577
+ let i = 0;
578
+ for (; i < tokens.length; i++) {
579
+ const tok = tokens[i] ?? "";
580
+ if (!tok.startsWith("$") || tok.length < 2) break;
581
+ const name = tok.slice(1);
582
+ if (/^[A-Z_][A-Z0-9_]*$/.test(name)) break; // env-var-style → boundary; keep in intent
583
+ const skill = getSkillFrom(skills, name) ?? uniquePrefixSkill(skills, name);
584
+ if (skill) invocations.push({ skill, intent: "", invokedAs: tok });
585
+ else unresolved.push(name);
586
+ }
587
+ if (invocations.length === 0 && unresolved.length === 0) return null;
588
+ const intent = tokens.slice(i).join(" ").trim();
589
+ return { invocations: invocations.map(inv => ({ ...inv, intent })), unresolved };
590
+ }
533
591
  export function looksLikeSkillEcho(reply: string, skills: SkillDoc[]): boolean {
534
592
  if (reply.length < 80) {
535
593
  return false;