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 +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/commands/launch.ts +174 -24
- package/src/skills/catalog.ts +32 -0
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
package/src/commands/launch.ts
CHANGED
|
@@ -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
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
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:${
|
|
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:
|
|
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 (
|
|
1636
|
+
if (inv.skill.name === "deep-interview") {
|
|
1634
1637
|
res = await runDeepInterviewEngine(opts);
|
|
1635
|
-
} else if (
|
|
1638
|
+
} else if (inv.skill.name === "ralplan") {
|
|
1636
1639
|
res = await runRalplanEngine(opts);
|
|
1637
|
-
} else if (
|
|
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:${
|
|
1655
|
-
: `[workflow:${
|
|
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: ${
|
|
1668
|
+
console.log(`▶ Running skill: ${inv.skill.name}${inv.intent ? ` — ${inv.intent}` : ""}`);
|
|
1666
1669
|
}
|
|
1667
|
-
const task = buildSkillTask(
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4143
|
-
// EXCEPT `$UPPERCASE` env-var-style tokens (e.g. `$HOME`)
|
|
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
|
|
4146
|
-
|
|
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) {
|
package/src/skills/catalog.ts
CHANGED
|
@@ -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;
|