diff --git a/AGENTS.md b/AGENTS.md index 913d857a..7b2661f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,12 @@ own rollout journals — see `docs/LANES.md#stage-detection`), but an inference only paints a dashed amber badge. It never counts as evidence and never renders a node green. Only your own declaration does. +If you go a while without declaring while that inference has clearly moved +past your last declared stage, the dashboard may type a one-line `[ccam]` +reminder straight into this pane asking you to run `ccam stage`. That is the +dashboard, not the user — treat it as the same instruction as this section and +just make the real call. + ## Where to work - `server/` for API/routes/data processing. - `client/` for React UI behavior. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bc8cfbd1..8f0f224d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1569,6 +1569,30 @@ at a live rollout file, so reading the JSONL avoids coupling to a schema the next Codex release renames. `rolloutFiles()` is the single function to re-point if that changes. +### Codex declared-stage reliability (`server/lib/codex-nudge.js`) + +Detection alone only paints the amber badge; a codex lane's *declared* stage +depends on the session calling `ccam stage` itself, so two mechanisms make +that more likely without weakening declared-wins/inferred-never-`done`: + +- **Opening-prompt priming** (`LaneConsolePane.tsx`'s `promptPrefill`): a codex + pane's first prompt is pre-filled with a plain-text checklist of + `ccam stage --tool codex` calls, one per node of `Lane.pipeline_nodes` + — the same up-front nudge a Claude lane gets from its pre-filled + `/ccam:ship-feature-lane` skill command, in plain text since Codex has no + slash commands. +- **Stale-detection nudge** (`sweepCodexNudges`, an unref'd 60 s interval from + `server/index.js`, tunable via `DASHBOARD_CODEX_NUDGE_MS`, `0` disables): + when a codex lane's fresh (non-stale) `detected_stage` has sat ahead of its + declared `stage` for at least two minutes and hasn't already been nudged for + that exact detected stage, it types a one-line `[ccam] ... run: ccam stage + ...` reminder into the lane's live tmux pane via `tmux.sendText` (skipped if + the pane isn't running an interactive program — `pty-run.js`'s + `SHELL_COMMANDS` — since Enter there would execute the reminder as a shell + command instead of submitting it as a chat turn). It never writes `stage` or + `detected_stage` — any resulting declaration is still one the codex session + made itself, through the real `ccam stage` call. + --- ## Agent Extension Layer diff --git a/README.md b/README.md index 77cf1a08..ab276812 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,11 @@ Sessions and Analytics. Its **stage detection** works anyway, from Codex's own rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every `DASHBOARD_CODEX_POLL_MS` ms, default 15000) — what a Codex lane cannot get for free is a *declared* stage, since only the session itself can call `ccam stage`. +Two things help it get there: the run console pre-fills a codex pane's opening +prompt with a stage-declaration checklist built from its own pipeline nodes, +and a 60s sweep (`DASHBOARD_CODEX_NUDGE_MS`, `0` disables) types a one-line +reminder into a lane's pane if its detected stage sits fresh and undeclared for +more than two minutes. The declared stage comes from `ccam stage`. The inferred stage comes from tool events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a lane can diff --git a/client/src/components/run/LaneConsolePane.tsx b/client/src/components/run/LaneConsolePane.tsx index 497bcc30..03cd3fb8 100644 --- a/client/src/components/run/LaneConsolePane.tsx +++ b/client/src/components/run/LaneConsolePane.tsx @@ -145,19 +145,27 @@ export function LaneConsolePane({ // templates never eats a typed requirement. Must be declared AFTER the // lane-switch effect: on a lane change that one clears both first. const lastPrefill = useRef(""); - // Claude lanes only: the value is a Claude Code slash command, and Codex - // would take it as literal prose — a prompt it cannot execute is worse than - // an empty box (it also means the pipeline the skill drives never runs, so - // the lane declares no stage at all). - const pipelineSkill = laneTool === "codex" ? "" : currentLane?.pipeline_skill || ""; + // Claude gets a slash command it can execute (`pipeline_skill`), which itself + // declares stage at every step. Codex has no slash commands — the same + // string would read as literal prose — so it gets the equivalent priming a + // different way: a plain-text checklist of this lane's own pipeline node ids, + // read directly off `pipeline_nodes` so it always matches the template in + // use. Without SOME opening nudge a codex lane's declared stage depends + // entirely on AGENTS.md being read on its own, which is easy to miss. + const promptPrefill = + laneTool === "codex" + ? currentLane?.pipeline_nodes?.length + ? `Declare progress as you go: run \`ccam stage --tool codex --evidence "..."\` immediately at each real transition through this pipeline — ${currentLane.pipeline_nodes.map((n) => n.id).join(" -> ")}. Then, your task:\n\n` + : "" + : currentLane?.pipeline_skill || ""; useEffect(() => { - const next = pipelineSkill ? `${pipelineSkill} ` : ""; + const next = promptPrefill && laneTool === "claude" ? `${promptPrefill} ` : promptPrefill; setPrompt((p) => (p === lastPrefill.current ? next : p)); lastPrefill.current = next; // `laneId` is a dep too: switching between two lanes on the SAME template - // leaves `pipelineSkill` unchanged, and the switch effect just cleared the + // leaves `promptPrefill` unchanged, and the switch effect just cleared the // prompt — without it the new lane would open with an empty box. - }, [pipelineSkill, laneId]); + }, [promptPrefill, laneTool, laneId]); // The switch effect above only sees whatever `activeRuns` the page already // had loaded at that instant. Remounting this page (navigating away and diff --git a/client/src/components/run/__tests__/LaneConsolePane.test.tsx b/client/src/components/run/__tests__/LaneConsolePane.test.tsx index 088bd4e9..83af289c 100644 --- a/client/src/components/run/__tests__/LaneConsolePane.test.tsx +++ b/client/src/components/run/__tests__/LaneConsolePane.test.tsx @@ -215,13 +215,40 @@ describe("LaneConsolePane", () => { it("never pre-fills a claude slash command into a codex pane", async () => { // pipeline_skill is a Claude Code slash command. Codex would take it as // literal prose and run nothing, which is how a codex lane sits for half an - // hour with an untouched pipeline map. + // hour with an untouched pipeline map. No pipeline_nodes here either, so + // there's nothing to build a stage checklist from — the box stays empty. const lane = { ...LANE, tool: "codex" as const, pipeline_skill: "/ccam:ship-feature-lane" }; render(); const prompt = screen.getByPlaceholderText(/ask claude/i) as HTMLTextAreaElement; await waitFor(() => expect(prompt.value).toBe("")); }); + it("pre-fills a codex pane with a stage-declaration checklist built from its own pipeline nodes", async () => { + // Codex has no slash commands, so it can't run pipeline_skill as-is — it + // gets the same priming Claude gets (an opening nudge to declare stage) + // as plain text instead, naming this lane's actual node ids. + const lane = { + ...LANE, + tool: "codex" as const, + pipeline_skill: "/ccam:ship-feature-lane", + pipeline_nodes: [ + { + id: "implement", + label: "Implement", + icon: "code", + gate: false, + state: "pending" as const, + }, + { id: "tests", label: "Tests", icon: "check", gate: false, state: "pending" as const }, + ], + }; + render(); + const prompt = screen.getByPlaceholderText(/ask claude/i) as HTMLTextAreaElement; + await waitFor(() => expect(prompt.value).toContain("ccam stage --tool codex")); + expect(prompt.value).toContain("implement -> tests"); + expect(prompt.value).not.toContain("/ccam:ship-feature-lane"); + }); + it("renders an empty placeholder with selector when laneId is null but showLaneSelector is true (split-view)", () => { render(); expect(screen.getByTestId("pane-empty")).toBeInTheDocument(); diff --git a/docs/LANES.md b/docs/LANES.md index c581dd83..fd8d22f8 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -77,7 +77,23 @@ Codex's own on-disk rollout journals rather than from hooks — see "Stage detection" below. What a codex lane cannot get for free is a *declared* stage: the run console never pre-fills a Claude slash command into a codex pane (Codex would read `/ccam:ship-feature-lane` as prose), so a codex session declares -stages only when something in the repository — an `AGENTS.md` — tells it to. +stages only when something tells it to. Two things do: + +- **An opening prompt checklist.** The Run Setup panel pre-fills a codex pane's + first prompt with a plain-text list of `ccam stage --tool codex` + calls to make, one per node of the lane's own pipeline template (built from + `Lane.pipeline_nodes`, so it always matches whatever template is in use) — + the same up-front priming a Claude lane gets from its pre-filled + `/ccam:ship-feature-lane` skill command, just as plain text since Codex has + no slash commands. See `LaneConsolePane.tsx`'s `promptPrefill`. +- **A stale-detection nudge.** `server/lib/codex-nudge.js` sweeps every 60s + (`DASHBOARD_CODEX_NUDGE_MS` to change, `0` to disable) for a codex lane whose + fresh, non-stale `detected_stage` has sat ahead of its declared `stage` for + at least two minutes, and types a one-line `[ccam] ... run: ccam stage ...` + reminder straight into that lane's live tmux pane (`tmux.sendText`, skipped + if the pane is at a bare shell prompt). It never writes `stage` or + `detected_stage` itself — declared-wins and inferred-never-`done` are + untouched — it only prods the session into making the real declaration. ## Destructive lane actions diff --git a/server/README.md b/server/README.md index b5b2410b..f93064f8 100644 --- a/server/README.md +++ b/server/README.md @@ -580,6 +580,23 @@ pass mirrors that lane's `status` between `idle` and `running` from the journal's mtime, under the guard hook ingestion uses (no `run_id`, only from `idle`/`running`); it writes nothing else. +Getting a codex lane's *declared* stage to actually happen is a separate +problem — only the session itself can call `ccam stage`, and unlike a Claude +lane it has no slash command to pre-fill. `LaneConsolePane.tsx` pre-fills a +codex pane's opening prompt with a plain-text checklist instead, one +`ccam stage --tool codex` line per node of the lane's own +`pipeline_nodes`. As a safety net beyond that opening nudge, +`server/lib/codex-nudge.js`'s `sweepCodexNudges()` runs on an unref'd 60s +interval (`DASHBOARD_CODEX_NUDGE_MS`, `0` disables): when a codex lane's fresh +`detected_stage` has sat ahead of its declared `stage` for at least two +minutes, it types a one-line `[ccam] ... run: ccam stage ...` reminder into +the lane's live tmux pane via `tmux.sendText` — skipped if the pane sits at a +bare shell prompt (`pty-run.js`'s `SHELL_COMMANDS`), since pressing Enter there +would run the reminder as a shell command instead of submitting it to Codex. +It never writes `stage` or `detected_stage`, so declared-wins and +inferred-never-`done` are untouched; any resulting declaration is still the +codex session's own real `ccam stage` call. + ### Remote Data Sources Live remote/multi-machine data collection over SSH. The dashboard pulls Claude Code history from other machines: `server/lib/remote-sync.js` uses recursive **`scp` over SSH** (built into OpenSSH — no `rsync` or extra packages on the remote) to mirror each remote's `~/.claude/projects` into a sandboxed per-source staging dir under the data dir, feeds it through the **same** importer used for local history (`scripts/import-history.js` `importFromDirectory`), and tags every imported session with the source id (`sessions.source`). Authentication defers entirely to the host SSH stack (ssh-agent / `~/.ssh/config` / identity file) — **no secrets are stored**; every command runs via `execFile`/`spawn` argument arrays (never a shell string) and `StrictHostKeyChecking` is left at its SSH default. diff --git a/server/__tests__/codex-nudge.test.js b/server/__tests__/codex-nudge.test.js new file mode 100644 index 00000000..27745b06 --- /dev/null +++ b/server/__tests__/codex-nudge.test.js @@ -0,0 +1,128 @@ +/** + * @file Unit tests for the codex stage-declaration safety net: nudges a live + * codex pane to run `ccam stage` when detection has clearly moved past the + * declared stage and stayed there, without ever writing stage itself. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const os = require("node:os"); +const path = require("node:path"); + +const SUITE_DB_PATH = path.join( + os.tmpdir(), + `dashboard-codex-nudge-${Date.now()}-${process.pid}.db` +); +process.env.DASHBOARD_DB_PATH = SUITE_DB_PATH; + +const { describe, it, beforeEach, afterEach } = require("node:test"); +const assert = require("node:assert/strict"); + +const { db } = require("../db"); +const lanes = require("../lib/lanes"); +const tmux = require("../lib/tmux"); +const nudge = require("../lib/codex-nudge"); + +/** Backdate a lane's detected_at so it looks `minutes` old to the sweep. */ +function backdateDetection(laneId, minutes) { + const iso = new Date(Date.now() - minutes * 60_000).toISOString(); + db.prepare("UPDATE lanes SET detected_at = ? WHERE id = ?").run(iso, laneId); +} + +describe("codex-nudge sweep", () => { + let calls; + + beforeEach(() => { + calls = []; + tmux.__setExecImpl((args) => { + calls.push(args); + if (args[0] === "display-message") return "codex"; // pane running the codex CLI + return ""; + }); + nudge.__resetNudgeState(); + }); + + afterEach(() => { + tmux.__reset(); + }); + + function makeCodexLane({ cwd }) { + const lane = lanes.createLane({ title: "cx", cwd, tool: "codex" }); + lanes.updateLane(lane.id, { run_id: `ccam-lane-${lane.id}` }); + lanes.recordDetection(lane.id, { nodeId: "tests", signal: "npm run test:server" }); + return lane; + } + + it("nudges once a fresh detection has sat ahead of the declared stage past the delay", () => { + const cwd = os.tmpdir(); + const lane = makeCodexLane({ cwd: `${cwd}/nudge-a` }); + backdateDetection(lane.id, 3); // older than the 2 min delay, well inside the 5 min TTL + + assert.equal(nudge.sweepCodexNudges(), 1); + const sendKeys = calls.filter((a) => a[0] === "send-keys" && a[3] === "-l"); + assert.equal(sendKeys.length, 1); + assert.match(sendKeys[0][4], /ccam stage tests --tool codex/); + + // A second pass for the same still-undeclared detection must not repeat it. + assert.equal(nudge.sweepCodexNudges(), 0); + lanes.deleteLane(lane.id); + }); + + it("never nudges before the delay has elapsed", () => { + const cwd = os.tmpdir(); + const lane = makeCodexLane({ cwd: `${cwd}/nudge-b` }); + // detected_at is "now" — recordDetection just set it. + assert.equal(nudge.sweepCodexNudges(), 0); + lanes.deleteLane(lane.id); + }); + + it("never nudges a stale detection (past the detection TTL)", () => { + const cwd = os.tmpdir(); + const lane = makeCodexLane({ cwd: `${cwd}/nudge-c` }); + backdateDetection(lane.id, 10); // past the default 5 min TTL + assert.equal(nudge.sweepCodexNudges(), 0); + lanes.deleteLane(lane.id); + }); + + it("never nudges a claude lane", () => { + const cwd = os.tmpdir(); + const lane = lanes.createLane({ title: "cc", cwd: `${cwd}/nudge-d`, tool: "claude" }); + lanes.updateLane(lane.id, { run_id: `ccam-lane-${lane.id}` }); + assert.equal(nudge.sweepCodexNudges(), 0); + lanes.deleteLane(lane.id); + }); + + it("never nudges a lane with no live run", () => { + const cwd = os.tmpdir(); + const lane = lanes.createLane({ title: "cx", cwd: `${cwd}/nudge-e`, tool: "codex" }); + lanes.recordDetection(lane.id, { nodeId: "tests", signal: "npm run test:server" }); + backdateDetection(lane.id, 3); + assert.equal(nudge.sweepCodexNudges(), 0); + lanes.deleteLane(lane.id); + }); + + it("never types into a pane sitting at a bare shell prompt", () => { + const cwd = os.tmpdir(); + const lane = makeCodexLane({ cwd: `${cwd}/nudge-f` }); + backdateDetection(lane.id, 3); + tmux.__setExecImpl((args) => { + calls.push(args); + if (args[0] === "display-message") return "bash"; + return ""; + }); + assert.equal(nudge.sweepCodexNudges(), 0); + lanes.deleteLane(lane.id); + }); + + it("nudges again once the target detected stage changes, after a prior nudge", () => { + const cwd = os.tmpdir(); + const lane = makeCodexLane({ cwd: `${cwd}/nudge-g` }); + backdateDetection(lane.id, 3); + assert.equal(nudge.sweepCodexNudges(), 1); + + // Detection advances to a later node; forward-only recordDetection accepts it. + lanes.recordDetection(lane.id, { nodeId: "review", signal: "gh pr create" }); + backdateDetection(lane.id, 3); + assert.equal(nudge.sweepCodexNudges(), 1); + lanes.deleteLane(lane.id); + }); +}); diff --git a/server/__tests__/tmux.test.js b/server/__tests__/tmux.test.js index 146bcdfc..3e39dabf 100644 --- a/server/__tests__/tmux.test.js +++ b/server/__tests__/tmux.test.js @@ -50,6 +50,23 @@ describe("tmux wrapper", () => { assert.deepEqual(calls[1], ["set-option", "-t", "ccam-lane-1", "mouse", "on"]); }); + it("sendText types the literal string and presses Enter, with no quoting", () => { + const calls = []; + tmux.__setExecImpl((args) => { + calls.push(args); + return ""; + }); + tmux.sendText("ccam-lane-1", 'Reminder: run "ccam stage implement"'); + assert.deepEqual(calls[0], [ + "send-keys", + "-t", + "ccam-lane-1", + "-l", + 'Reminder: run "ccam stage implement"', + ]); + assert.deepEqual(calls[1], ["send-keys", "-t", "ccam-lane-1", "Enter"]); + }); + it("killSession never throws when the session is already gone", () => { tmux.__setExecImpl(() => { const e = new Error("no such session"); diff --git a/server/index.js b/server/index.js index 3026cdac..02c839ba 100644 --- a/server/index.js +++ b/server/index.js @@ -404,6 +404,15 @@ function startBackgroundServices() { } catch (err) { console.warn("codex poll failed to start:", err.message); } + // Safety net: a codex lane only declares stage when the session calls + // `ccam stage` itself. When auto-detection has clearly moved on without a + // matching declaration, nudge the session's own pane to make the real call + // — never writes stage itself, so the declared-wins invariant is untouched. + try { + require("./lib/codex-nudge").startCodexNudge(); + } catch (err) { + console.warn("codex nudge failed to start:", err.message); + } // Continuous discovery of sessions under ~/.claude/projects. The one-time // legacy backfill above runs only once (marker-gated), so a project added // later whose sessions never flow through hooks would otherwise stay invisible diff --git a/server/lib/codex-nudge.js b/server/lib/codex-nudge.js new file mode 100644 index 00000000..9f25f2a4 --- /dev/null +++ b/server/lib/codex-nudge.js @@ -0,0 +1,102 @@ +/** + * @file Safety-net reminder for codex lanes whose auto-detected stage has + * clearly moved past the last stage the session actually *declared* (see + * `server/lib/stage-detect.js` / `server/lib/codex-ingest.js`). A codex + * session only declares a stage when it calls `ccam stage` on its own — + * there is no hook or wrapper that does it for it — so a lane can sit with a + * stale declared stage indefinitely even while its pipeline map keeps + * painting fresh amber "detected" badges. + * + * This module never writes `stage` or `detected_stage` itself — the + * declared-wins / inferred-never-`done` invariants in lanes.js are untouched. + * It only types a plain-text reminder into the lane's own live tmux pane + * (`tmux.sendText`), the same way a human watching the dashboard would nudge + * the agent — so any stage change that results is still a REAL declaration + * the codex session made itself. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const NUDGE_DELAY_MS = 120_000; // give the session 2 min to declare on its own first + +// Per-lane memory of the last detected_stage already nudged for, so the 60s +// sweep doesn't retype the same reminder every pass. Cleared implicitly: once +// the session declares (setStage nulls detected_stage) or detection advances +// again, the value here no longer matches and a fresh nudge is due. +// ponytail: in-memory, resets on server restart — worst case one duplicate +// nudge after a restart, not worth a DB column for. +const nudgedFor = new Map(); + +/** + * One sweep pass: nudge every codex lane whose fresh detection sits ahead of + * its declared stage, has held for at least NUDGE_DELAY_MS, and hasn't + * already been nudged for that exact detected stage. + * @returns {number} How many lanes were nudged. + */ +function sweepCodexNudges() { + const lanes = require("./lanes"); + const tmux = require("./tmux"); + const { SHELL_COMMANDS, runIdForLane } = require("./pty-run"); + + let codexLanes = []; + try { + codexLanes = lanes.listLanes().filter((l) => l.tool === "codex" && l.run_id); + } catch { + return 0; + } + + let nudged = 0; + for (const lane of codexLanes) { + try { + if (!lane.detected_stage || lane.detected_stage === lane.stage) { + nudgedFor.delete(lane.id); + continue; + } + if (lanes.detectionIsStale(lane.detected_at)) continue; + const detectedAt = Date.parse(lane.detected_at); + if (!Number.isFinite(detectedAt) || Date.now() - detectedAt < NUDGE_DELAY_MS) continue; + if (nudgedFor.get(lane.id) === lane.detected_stage) continue; + + const runId = runIdForLane(lane.id); + if (!tmux.hasSession(runId)) continue; + // A bare shell prompt has no chat turn to submit into — pressing Enter + // there would run the reminder text as a shell command instead. + if (SHELL_COMMANDS.has(tmux.paneCommand(runId) || "")) continue; + + tmux.sendText( + runId, + `[ccam] You appear to have moved on to "${lane.detected_stage}" — run: ccam stage ${lane.detected_stage} --tool codex --evidence "..."` + ); + nudgedFor.set(lane.id, lane.detected_stage); + nudged += 1; + } catch { + /* one lane's failure must not abort the sweep */ + } + } + return nudged; +} + +/** Unref'd so it never blocks shutdown; disable with DASHBOARD_CODEX_NUDGE_MS=0. */ +function startCodexNudge() { + const POLL_MS = process.env.DASHBOARD_CODEX_NUDGE_MS + ? Number(process.env.DASHBOARD_CODEX_NUDGE_MS) + : 60_000; + if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return; + const timer = setInterval(() => { + try { + sweepCodexNudges(); + } catch { + /* a sweep pass never throws into the event loop */ + } + }, POLL_MS); + if (timer.unref) timer.unref(); +} + +function __resetNudgeState() { + nudgedFor.clear(); +} + +module.exports = { + sweepCodexNudges, + startCodexNudge, + __resetNudgeState, +}; diff --git a/server/lib/lanes.js b/server/lib/lanes.js index 3aa81e8f..425908e4 100644 --- a/server/lib/lanes.js +++ b/server/lib/lanes.js @@ -633,6 +633,7 @@ function purgeLaneSessions(laneId) { module.exports = { DEAD_SEC, VALID_TOOLS, + detectionIsStale, createLane, listLanes, getLane, diff --git a/server/lib/pty-run.js b/server/lib/pty-run.js index b14daede..958e4b84 100644 --- a/server/lib/pty-run.js +++ b/server/lib/pty-run.js @@ -288,4 +288,5 @@ module.exports = { listRuns, laneIdFromRunId, runIdForLane, + SHELL_COMMANDS, }; diff --git a/server/lib/tmux.js b/server/lib/tmux.js index 1a326eb5..32aec13e 100644 --- a/server/lib/tmux.js +++ b/server/lib/tmux.js @@ -9,6 +9,8 @@ * The one place a command line is composed is `sendCommand`, which types into * an existing pane's shell: there the shell IS the consumer, so every argument * is POSIX single-quoted first and sent with `send-keys -l` (literal). + * `sendText` is the same primitive for the opposite case — a pane running an + * interactive chat program that reads stdin as a message, not a shell. * @author Nguyễn Ngọc Trí Vĩ */ @@ -104,6 +106,20 @@ function sendCommand(name, argv, env) { execImpl(["send-keys", "-t", name, "Enter"]); } +/** + * Type a literal line of plain text into an existing pane and press Enter — + * for a program that reads stdin as a message rather than a shell command + * (an interactive `claude`/`codex` chat pane), where `sendCommand`'s POSIX + * quoting and `KEY=value ` env prefix would show up as literal punctuation in + * the message instead of being interpreted. `text` must be single-line: `-l` + * sends it as one literal keystroke burst, so an embedded newline would submit + * only the first line and leave the rest sitting unsent in the input box. + */ +function sendText(name, text) { + execImpl(["send-keys", "-t", name, "-l", text]); + execImpl(["send-keys", "-t", name, "Enter"]); +} + /** Idempotent — a session that's already gone is not an error. */ function killSession(name) { try { @@ -142,6 +158,7 @@ module.exports = { enableMouse, paneCommand, sendCommand, + sendText, killSession, listSessions, isTmuxAvailable,