jeo-code 0.5.2 → 0.5.4

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.4]** (2026-06-15) — Reliable multi-line input is ON by default — a paste fills the box and submits as one message.
154
+ - **[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.
153
155
  - **[0.5.2]** (2026-06-14) — `$skill` prompt invocation with prefix/fuzzy suggestions, and a per-session input-box hue (amber in cmd-mode).
154
156
  - **[0.5.1]** (2026-06-14) — cmd-mode `!<command>` shell escape — run a shell command without engaging the agent.
155
157
  - **[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.
156
- - **[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.
157
- - **[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.
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.4]** (2026-06-15) — Reliable multi-line input is ON by default — a paste fills the box and submits as one message.
154
+ - **[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.
153
155
  - **[0.5.2]** (2026-06-14) — `$skill` prompt invocation with prefix/fuzzy suggestions, and a per-session input-box hue (amber in cmd-mode).
154
156
  - **[0.5.1]** (2026-06-14) — cmd-mode `!<command>` shell escape — run a shell command without engaging the agent.
155
157
  - **[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.
156
- - **[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.
157
- - **[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.
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.4]** (2026-06-15) — Reliable multi-line input is ON by default — a paste fills the box and submits as one message.
154
+ - **[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.
153
155
  - **[0.5.2]** (2026-06-14) — `$skill` prompt invocation with prefix/fuzzy suggestions, and a per-session input-box hue (amber in cmd-mode).
154
156
  - **[0.5.1]** (2026-06-14) — cmd-mode `!<command>` shell escape — run a shell command without engaging the agent.
155
157
  - **[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.
156
- - **[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.
157
- - **[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.
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.4]** (2026-06-15) — Reliable multi-line input is ON by default — a paste fills the box and submits as one message.
154
+ - **[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.
153
155
  - **[0.5.2]** (2026-06-14) — `$skill` prompt invocation with prefix/fuzzy suggestions, and a per-session input-box hue (amber in cmd-mode).
154
156
  - **[0.5.1]** (2026-06-14) — cmd-mode `!<command>` shell escape — run a shell command without engaging the agent.
155
157
  - **[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.
156
- - **[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.
157
- - **[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.
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.2",
3
+ "version": "0.5.4",
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,73 @@ 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 ───────────────────────────────────────────────────────────
1918
+ // Reliable multi-line input: a bracketed paste arrives as ONE buffer (fills the box,
1919
+ // submits intact) and Shift+Enter can insert a newline. We route the prompt's stdin
1920
+ // through a filter that rewrites line breaks to a private-use SENTINEL char BEFORE
1921
+ // readline sees them — readline inserts it as an ordinary character (no per-line
1922
+ // submit/race), the box renders it as a real line break, and it is expanded back to
1923
+ // "\n" on submit. On for any interactive TTY; JEO_NO_MULTILINE=1 reads stdin directly.
1924
+ const SENTINEL = "\uE000";
1925
+ const SHIFT_ENTER_SEQS = ["\u001b[27;2;13~", "\u001b[13;2u"];
1926
+ // Multi-line input filter is ON for any interactive TTY: reliable multi-line paste
1927
+ // (fills the box, submits intact into the user card) is the default. The lone-"\n"
1928
+ // Shift+Enter rule stays opt-in (JEO_MULTILINE=1) — it needs ghostty's
1929
+ // `keybind = shift+enter=text:\n` and could misfire on the rare terminal that sends
1930
+ // LF for Enter. JEO_NO_MULTILINE=1 fully disables the filter (reads stdin directly).
1931
+ const multilineInput = !!process.stdin.isTTY && jeoEnv("NO_MULTILINE") !== "1";
1932
+ const loneLfShiftEnter = jeoEnv("MULTILINE") === "1";
1933
+ const expandSentinel = (s: string): string => (multilineInput ? s.split(SENTINEL).join("\n") : s);
1934
+ let keyFilter: PassThrough | undefined;
1935
+ if (multilineInput) {
1936
+ const kf = new PassThrough();
1937
+ (kf as unknown as { isTTY: boolean }).isTTY = true;
1938
+ (kf as unknown as { setRawMode: (m: boolean) => unknown }).setRawMode = (m: boolean) => {
1939
+ try { (process.stdin as { setRawMode?(r: boolean): void }).setRawMode?.(m); } catch { /* terminal gone */ }
1940
+ return kf;
1941
+ };
1942
+ Object.defineProperty(kf, "isRaw", { get: () => (process.stdin as { isRaw?: boolean }).isRaw });
1943
+ // Forward stdin → filter, rewriting line breaks into a newline SENTINEL BEFORE
1944
+ // readline sees them, so multi-line input arrives as ONE buffer (no per-line submit
1945
+ // and no racy paste-merge). Stateful across chunks:
1946
+ // • Inside a bracketed paste (200~..201~): every line break → sentinel, so the
1947
+ // whole paste inserts as one multi-line buffer that the user reviews + submits
1948
+ // with Enter (fixes "paste only kept line 1").
1949
+ // • Outside a paste: Shift+Enter encodings → sentinel — a lone "\n" (ghostty
1950
+ // `keybind = shift+enter=text:\n`, passes tmux unchanged even with extended-keys
1951
+ // off) and the xterm "\x1b[27;2;13~" / kitty "\x1b[13;2u" sequences. Enter ("\r")
1952
+ // passes through and submits.
1953
+ let kfInPaste = false;
1954
+ process.stdin.on("data", (chunk: Buffer) => {
1955
+ const data = chunk.toString("utf8");
1956
+ let out = "";
1957
+ let i = 0;
1958
+ while (i < data.length) {
1959
+ if (!kfInPaste && data.startsWith(PASTE_START, i)) { kfInPaste = true; out += PASTE_START; i += PASTE_START.length; continue; }
1960
+ if (kfInPaste && data.startsWith(PASTE_END, i)) { kfInPaste = false; out += PASTE_END; i += PASTE_END.length; continue; }
1961
+ if (kfInPaste) {
1962
+ if (data.startsWith("\r\n", i)) { out += SENTINEL; i += 2; continue; }
1963
+ if (data[i] === "\n" || data[i] === "\r") { out += SENTINEL; i += 1; continue; }
1964
+ out += data[i]; i += 1; continue;
1965
+ }
1966
+ let matched = false;
1967
+ for (const seq of SHIFT_ENTER_SEQS) {
1968
+ if (data.startsWith(seq, i)) { out += SENTINEL; i += seq.length; matched = true; break; }
1969
+ }
1970
+ if (matched) continue;
1971
+ if (loneLfShiftEnter && data[i] === "\n") { out += SENTINEL; i += 1; continue; } // lone LF = Shift+Enter (opt-in)
1972
+ out += data[i]; i += 1;
1973
+ }
1974
+ kf.write(out);
1975
+ });
1976
+ keyFilter = kf;
1977
+ // readline now decodes keypresses on `keyFilter`; keep process.stdin emitting
1978
+ // 'keypress' too so the footer-redraw / paste-marker / picker listeners (registered
1979
+ // on process.stdin below) still fire.
1980
+ emitKeypressEvents(process.stdin);
1981
+ }
1894
1982
  const rl = createInterface({
1895
- input: process.stdin,
1983
+ input: keyFilter ?? process.stdin,
1896
1984
  // Single-box input: gate readline's output while the boxed footer is armed so its own
1897
1985
  // `jeo>` prompt/echo is suppressed and ONLY our box shows. (Bun exposes no
1898
1986
  // `_writeToOutput` to patch, so gating the shared output stream is the portable fix.)
@@ -1937,10 +2025,19 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1937
2025
  // outside it keep the existing contracts (typed-line prefill fold on a TTY,
1938
2026
  // in-order auto-serve for piped stdin).
1939
2027
  let promptPasteActive = false;
2028
+ // PASTE-MERGE: a multi-line bracketed paste must arrive as ONE message, not split into
2029
+ // one command per line. Lines that fire WHILE a paste is open are buffered here instead
2030
+ // of run individually; promptInput joins them with the trailing residual on resolve.
2031
+ // `endWaiters` wake that merge the moment the 201~ paste-end marker arrives.
2032
+ const pasteMerge: { buf: string[]; endWaiters: Array<() => void> } = { buf: [], endWaiters: [] };
2033
+ let pasteLineFired = false; // the line that resolved rl.question came from inside a paste
1940
2034
  if (process.stdin.isTTY) {
1941
2035
  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;
2036
+ if (key?.name === "paste-start") { promptPasteActive = true; pasteMerge.buf = []; }
2037
+ else if (key?.name === "paste-end") {
2038
+ promptPasteActive = false;
2039
+ for (const w of pasteMerge.endWaiters.splice(0)) w();
2040
+ }
1944
2041
  });
1945
2042
  // Enable bracketed paste for the REPL lifetime (restored on exit below):
1946
2043
  // terminals only wrap pastes in the 200~/201~ markers once the app opts in.
@@ -1949,7 +2046,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1949
2046
  }
1950
2047
  rl.on("line", l => {
1951
2048
  if (promptPasteActive) {
1952
- queuedPromptInput.pastedLines.push(l);
2049
+ // Inside a bracketed paste: buffer the line, never run it on its own.
2050
+ pasteMerge.buf.push(l);
2051
+ pasteLineFired = true;
1953
2052
  return;
1954
2053
  }
1955
2054
  if (!interactiveTurnActive) pendingStdinLines.push(l);
@@ -1989,8 +2088,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1989
2088
  return "/exit";
1990
2089
  }
1991
2090
  promptActive = true;
2091
+ pasteLineFired = false;
1992
2092
  try {
1993
- return await Promise.race([
2093
+ const value = await Promise.race([
1994
2094
  rl.question(prompt),
1995
2095
  new Promise<string>(resolve => {
1996
2096
  notifyStdinClosed = () => {
@@ -1999,6 +2099,29 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1999
2099
  };
2000
2100
  }),
2001
2101
  ]);
2102
+ // PASTE-MERGE: if the resolving line came from inside a bracketed paste, the rest of
2103
+ // the paste (buffered in pasteMerge.buf) plus the trailing residual still in the line
2104
+ // buffer belong to the SAME message. Wait for paste-end, then join them with newlines
2105
+ // and return ONE multi-line input instead of running line 1 and queuing the rest.
2106
+ if (pasteLineFired) {
2107
+ if (promptPasteActive) {
2108
+ await new Promise<void>(resolve => {
2109
+ if (!promptPasteActive) { resolve(); return; }
2110
+ pasteMerge.endWaiters.push(resolve);
2111
+ setTimeout(resolve, 250); // safety: never hang if the 201~ marker is dropped
2112
+ });
2113
+ }
2114
+ const residual = (rl as unknown as { line?: string }).line ?? "";
2115
+ // Clear the residual so it does NOT prefill (and re-submit) the next prompt.
2116
+ try { rl.write(null, { ctrl: true, name: "u" }); } catch { /* best-effort */ }
2117
+ try { (rl as unknown as { line: string; cursor: number }).line = ""; (rl as unknown as { cursor: number }).cursor = 0; } catch { /* best-effort */ }
2118
+ const parts = [...pasteMerge.buf];
2119
+ if (residual !== "") parts.push(residual);
2120
+ pasteMerge.buf = [];
2121
+ pasteLineFired = false;
2122
+ return expandSentinel(parts.join("\n"));
2123
+ }
2124
+ return expandSentinel(value);
2002
2125
  } finally {
2003
2126
  promptActive = false;
2004
2127
  notifyStdinClosed = undefined;
@@ -2204,7 +2327,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2204
2327
  const rli = rl as unknown as { line?: string; cursor?: number };
2205
2328
  const caret = rli.line === line && typeof rli.cursor === "number" ? rli.cursor : line.length;
2206
2329
  const { accent: boxAccent, shadow: boxShadow } = boxAccents(line);
2207
- const frame = renderInputFrame(line, {
2330
+ const frame = renderInputFrame(expandSentinel(line), {
2208
2331
  cols,
2209
2332
  color: true,
2210
2333
  unicode: true,
@@ -4129,6 +4252,25 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4129
4252
  }
4130
4253
  continue;
4131
4254
  }
4255
+ // `$a $b ... [intent]` — run EVERY resolved skill in the leading run, in order, each
4256
+ // with the shared trailing intent. A lone `$skill` is just a chain of length 1.
4257
+ const dollarChain = input.startsWith("$") ? parseSkillChain(input, resolvedSkills) : null;
4258
+ if (dollarChain && dollarChain.invocations.length) {
4259
+ if (dollarChain.unresolved.length) {
4260
+ console.log(`(skipping unknown skill${dollarChain.unresolved.length > 1 ? "s" : ""}: ${dollarChain.unresolved.map(u => `$${u}`).join(", ")})`);
4261
+ }
4262
+ if (dollarChain.invocations.length > 1) {
4263
+ console.log(`▶ Chaining ${dollarChain.invocations.length} skills: ${dollarChain.invocations.map(i => `$${i.skill.name}`).join(" → ")}`);
4264
+ }
4265
+ for (const inv of dollarChain.invocations) {
4266
+ try {
4267
+ await runSkillInvocation(inv.skill, inv.intent, inv.invokedAs);
4268
+ } catch (err) {
4269
+ console.log(`! ${(err as Error).message}`);
4270
+ }
4271
+ }
4272
+ continue;
4273
+ }
4132
4274
  const aliasInvocation = parseSkillInvocation(input, resolvedSkills);
4133
4275
  if (aliasInvocation?.invokedAs) {
4134
4276
  try {
@@ -4139,11 +4281,19 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4139
4281
  continue;
4140
4282
  }
4141
4283
  // Unresolved `$skill` → suggest precisely, never silently send the typo to the model.
4142
- // `$exact`/`$prefix` already ran above; a leftover `$word` is a missed skill attempt,
4143
- // EXCEPT `$UPPERCASE` env-var-style tokens (e.g. `$HOME`), which pass through untouched.
4284
+ // `$exact`/`$prefix`/chains already ran above; a leftover `$word` is a missed skill
4285
+ // attempt, EXCEPT `$UPPERCASE` env-var-style tokens (e.g. `$HOME`) which pass through.
4144
4286
  if (input.startsWith("$")) {
4145
- const token = (input.split(/\s+/, 1)[0] ?? "").slice(1);
4146
- if (token && !/^[A-Z_][A-Z0-9_]*$/.test(token)) {
4287
+ const unresolved = (dollarChain?.unresolved.length
4288
+ ? dollarChain.unresolved
4289
+ : [(input.split(/\s+/, 1)[0] ?? "").slice(1)]
4290
+ ).filter(t => t && !/^[A-Z_][A-Z0-9_]*$/.test(t));
4291
+ if (unresolved.length > 1) {
4292
+ console.log(`No skills: ${unresolved.map(u => `$${u}`).join(", ")}. (Type $ to autocomplete.)`);
4293
+ continue;
4294
+ }
4295
+ if (unresolved.length === 1) {
4296
+ const token = unresolved[0]!;
4147
4297
  const lc = token.toLowerCase();
4148
4298
  const prefix = resolvedSkills.filter(s => s.name.toLowerCase().startsWith(lc));
4149
4299
  if (prefix.length) {
@@ -556,6 +556,38 @@ export function parseSkillInvocation(input: string, skills: SkillDoc[]): SkillIn
556
556
  }
557
557
  return skill ? { skill, intent: trimmed.slice(command.length).trim(), invokedAs: command } : null;
558
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
+ }
559
591
  export function looksLikeSkillEcho(reply: string, skills: SkillDoc[]): boolean {
560
592
  if (reply.length < 80) {
561
593
  return false;