tuiboard 0.11.0 → 0.13.0
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/.tuiboard/config.example.yaml +33 -9
- package/CHANGELOG.md +127 -0
- package/README.md +116 -30
- package/bin/tuiboard.ts +9 -0
- package/package.json +3 -2
- package/src/app-exit.ts +37 -0
- package/src/app.tsx +23 -13
- package/src/config/loader.ts +40 -7
- package/src/input/handleKey.ts +115 -52
- package/src/input/herdr-open.ts +132 -0
- package/src/input/open-session.ts +452 -0
- package/src/store/agent-adapters/claude-code.ts +349 -0
- package/src/store/agent-adapters/codex.ts +406 -0
- package/src/store/agent-adapters/index.ts +17 -0
- package/src/store/agent-adapters/opencode.ts +196 -0
- package/src/store/agent-adapters/pi.ts +254 -0
- package/src/store/agents.ts +182 -342
- package/src/store/herdr.ts +272 -0
- package/src/store/index.ts +48 -8
- package/src/ui/AgentRow.tsx +188 -50
- package/src/ui/AgentsBar.tsx +9 -3
- package/src/ui/Modal.tsx +63 -6
- package/src/ui/agent-line.ts +95 -0
- package/src/views/AgentsOnly.tsx +12 -5
package/src/config/loader.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
|
19
19
|
import * as YAML from "js-yaml";
|
|
20
20
|
|
|
21
21
|
import { isBoardFile } from "~/boards/scan";
|
|
22
|
+
import { LAUNCHERS, SHELLS, type Launcher, type Shell } from "~/input/open-session";
|
|
22
23
|
|
|
23
24
|
export interface BoardConfig {
|
|
24
25
|
/** Path to the .md file, absolute or relative to the config directory. */
|
|
@@ -38,20 +39,39 @@ export interface Config {
|
|
|
38
39
|
archiveColumn: string;
|
|
39
40
|
/**
|
|
40
41
|
* Optional override for "open the selected agent session" (Enter in the
|
|
41
|
-
* agents zone). An argv array; the tokens `{cwd}
|
|
42
|
-
*
|
|
42
|
+
* agents zone). An argv array; the tokens `{cwd}`, `{sessionId}` and
|
|
43
|
+
* `{resume}` (the agent's own resume command, e.g. `claude --resume <id>`)
|
|
44
|
+
* are substituted, then it's spawned directly (no shell). Point it at your own
|
|
43
45
|
* script to launch a custom terminal layout — e.g.
|
|
44
46
|
* ["pwsh", "-NoProfile", "-File", "C:/.../code-resume.ps1", "{cwd}", "{sessionId}"]
|
|
45
|
-
* When unset, tuiboard falls back to opening a tab +
|
|
47
|
+
* When unset, tuiboard falls back to opening a tab + the agent's resume command.
|
|
46
48
|
*/
|
|
47
49
|
resumeCommand?: string[];
|
|
50
|
+
/**
|
|
51
|
+
* Terminal Enter opens sessions in (config `resume_terminal`). `auto`
|
|
52
|
+
* detects it from the environment; set one explicitly when detection
|
|
53
|
+
* guesses wrong. Ignored when `resumeCommand` is set.
|
|
54
|
+
*/
|
|
55
|
+
resumeTerminal: "auto" | Launcher;
|
|
56
|
+
/**
|
|
57
|
+
* Shell the resumed session runs in (config `resume_shell`). `auto` = the
|
|
58
|
+
* shell tuiboard was started from (Git Bash / Nushell / PowerShell on
|
|
59
|
+
* Windows, `$SHELL` elsewhere). Ignored when `resumeCommand` is set.
|
|
60
|
+
*/
|
|
61
|
+
resumeShell: "auto" | Shell;
|
|
62
|
+
/**
|
|
63
|
+
* Agent status glyphs (config `status_indicators`): `symbols` (default —
|
|
64
|
+
* the same × ◐ ✓ ○ · herdr uses) or `dots` (● ○ ·).
|
|
65
|
+
*/
|
|
66
|
+
statusIndicators: "dots" | "symbols";
|
|
48
67
|
/**
|
|
49
68
|
* Template for the shell command copied to the clipboard by `c` in the agents
|
|
50
69
|
* zone — one paste that `cd`s into the session's directory and resumes it.
|
|
51
|
-
* The tokens `{cwd}` and `{
|
|
52
|
-
*
|
|
70
|
+
* The tokens `{cwd}`, `{sessionId}` and `{resume}` (the agent's own resume
|
|
71
|
+
* command, e.g. `claude --resume <id>`) are substituted. Default:
|
|
72
|
+
* cd "{cwd}" && {resume}
|
|
53
73
|
* `&&` works in bash/zsh/pwsh/cmd; Nushell users may prefer
|
|
54
|
-
* cd "{cwd}";
|
|
74
|
+
* cd "{cwd}"; {resume}
|
|
55
75
|
*/
|
|
56
76
|
copyResumeCommand: string;
|
|
57
77
|
/**
|
|
@@ -118,12 +138,15 @@ export interface CalendarsConfig {
|
|
|
118
138
|
* paste. `&&` chains in bash/zsh/pwsh/cmd (Nushell users override with `;`).
|
|
119
139
|
*/
|
|
120
140
|
export const DEFAULT_COPY_RESUME_COMMAND =
|
|
121
|
-
'cd "{cwd}" &&
|
|
141
|
+
'cd "{cwd}" && {resume}';
|
|
122
142
|
|
|
123
143
|
export const DEFAULT_CONFIG: Omit<Config, "root" | "loaded" | "boards"> = {
|
|
124
144
|
assignees: [],
|
|
125
145
|
doneColumn: "Done",
|
|
126
146
|
archiveColumn: "Archive",
|
|
147
|
+
resumeTerminal: "auto",
|
|
148
|
+
resumeShell: "auto",
|
|
149
|
+
statusIndicators: "symbols",
|
|
127
150
|
copyResumeCommand: DEFAULT_COPY_RESUME_COMMAND,
|
|
128
151
|
zones: { planner: "on", agenda: "on", agents: "on" },
|
|
129
152
|
};
|
|
@@ -197,6 +220,9 @@ interface RawConfig {
|
|
|
197
220
|
done_column: string;
|
|
198
221
|
archive_column: string;
|
|
199
222
|
resume_command: string[];
|
|
223
|
+
resume_terminal: string;
|
|
224
|
+
resume_shell: string;
|
|
225
|
+
status_indicators: string;
|
|
200
226
|
copy_resume_command: string;
|
|
201
227
|
calendars: {
|
|
202
228
|
google?: {
|
|
@@ -342,6 +368,13 @@ function normalize(raw: Partial<RawConfig>, root: string, loaded: boolean): Conf
|
|
|
342
368
|
Array.isArray(raw.resume_command) && raw.resume_command.length > 0
|
|
343
369
|
? raw.resume_command.map(String)
|
|
344
370
|
: undefined,
|
|
371
|
+
resumeTerminal: (LAUNCHERS as readonly string[]).includes(raw.resume_terminal ?? "")
|
|
372
|
+
? (raw.resume_terminal as Launcher)
|
|
373
|
+
: "auto",
|
|
374
|
+
resumeShell: (SHELLS as readonly string[]).includes(raw.resume_shell ?? "")
|
|
375
|
+
? (raw.resume_shell as Shell)
|
|
376
|
+
: "auto",
|
|
377
|
+
statusIndicators: raw.status_indicators === "dots" ? "dots" : "symbols",
|
|
345
378
|
copyResumeCommand:
|
|
346
379
|
typeof raw.copy_resume_command === "string" &&
|
|
347
380
|
raw.copy_resume_command.trim().length > 0
|
package/src/input/handleKey.ts
CHANGED
|
@@ -13,7 +13,19 @@
|
|
|
13
13
|
* shares the same input contract.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { quitApp } from "~/app-exit";
|
|
16
17
|
import { isHiddenColumn } from "~/config/loader";
|
|
18
|
+
import {
|
|
19
|
+
LAUNCHER_NAME,
|
|
20
|
+
describePlan,
|
|
21
|
+
detectLauncher,
|
|
22
|
+
planLaunch,
|
|
23
|
+
runLaunchPlan,
|
|
24
|
+
systemLaunchEnv,
|
|
25
|
+
} from "~/input/open-session";
|
|
26
|
+
import { planHerdrFocus, planHerdrResume } from "~/input/herdr-open";
|
|
27
|
+
import { HARNESS, type AgentSession } from "~/store/agents";
|
|
28
|
+
import { herdrBin, herdrPlace } from "~/store/herdr";
|
|
17
29
|
import { googleTokenCanWrite } from "~/store/calendar";
|
|
18
30
|
import { isTask } from "~/parser/markdown";
|
|
19
31
|
import {
|
|
@@ -126,7 +138,7 @@ export function handleKey(
|
|
|
126
138
|
|
|
127
139
|
// Quit
|
|
128
140
|
if (key.name === "q" || (key.ctrl && key.name === "c")) {
|
|
129
|
-
|
|
141
|
+
void quitApp(() => store.dispose());
|
|
130
142
|
return;
|
|
131
143
|
}
|
|
132
144
|
|
|
@@ -227,6 +239,13 @@ export function handleKey(
|
|
|
227
239
|
// Cycle the board filter — affects which open tasks show up in board
|
|
228
240
|
// columns. Mirrors Python kanban `action_cycle_filter`. Cycle order:
|
|
229
241
|
// all → today → overdue → tomorrow → followup → all.
|
|
242
|
+
// In the Agents zone `f` filters sessions by harness instead.
|
|
243
|
+
if (key.name === "f" && ui.activeZone === "agents") {
|
|
244
|
+
const next = store.cycleAgentsFilter();
|
|
245
|
+
const label = next === "all" ? "all harnesses" : `${HARNESS[next].code} · ${HARNESS[next].name}`;
|
|
246
|
+
store.flashBanner("info", `Agents: ${label}`);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
230
249
|
if (key.name === "f") {
|
|
231
250
|
const cycle = ["all", "today", "overdue", "tomorrow", "followup"] as const;
|
|
232
251
|
const idx = cycle.indexOf(ui.filter);
|
|
@@ -477,24 +496,31 @@ function handleTimelineZone(
|
|
|
477
496
|
|
|
478
497
|
function handleAgentsZone(store: TuiStore, key: KeyEvent): void {
|
|
479
498
|
const ui = store.state.ui;
|
|
480
|
-
const sessions = store.
|
|
499
|
+
const sessions = store.agentSessions();
|
|
481
500
|
if (key.name === "j" || key.name === "down") {
|
|
482
501
|
store.setCursor(0, Math.min(sessions.length - 1, ui.row + 1));
|
|
483
502
|
} else if (key.name === "k" || key.name === "up") {
|
|
484
503
|
store.setCursor(0, Math.max(0, ui.row - 1));
|
|
504
|
+
} else if (key.name === "H" || (key.name === "h" && key.shift)) {
|
|
505
|
+
// Jump to the session in herdr, or resume it there.
|
|
506
|
+
const target = sessions[ui.row];
|
|
507
|
+
if (target) void openInHerdr(store, target);
|
|
485
508
|
} else if (key.name === "enter" || key.name === "return") {
|
|
486
|
-
// Open (resume) the selected session in a new
|
|
509
|
+
// Open (resume) the selected session in a new terminal tab/window —
|
|
510
|
+
// or, when it's already open in herdr, go there instead of a second copy.
|
|
487
511
|
const target = sessions[ui.row];
|
|
488
|
-
if (target) void
|
|
512
|
+
if (target?.herdr) void openInHerdr(store, target);
|
|
513
|
+
else if (target) void openSession(store, target);
|
|
489
514
|
} else if (key.name === "c") {
|
|
490
515
|
// Copy a one-paste "cd + resume" command for the selected session, so you
|
|
491
516
|
// can drop it into any tab/pane anywhere and land in the right directory
|
|
492
|
-
// resuming the right session (
|
|
517
|
+
// resuming the right session (works in any terminal, unlike Enter).
|
|
493
518
|
const target = sessions[ui.row];
|
|
494
519
|
if (target) {
|
|
495
520
|
const cmd = store.config.copyResumeCommand
|
|
496
521
|
.replaceAll("{cwd}", target.cwd)
|
|
497
|
-
.replaceAll("{sessionId}", target.sessionId)
|
|
522
|
+
.replaceAll("{sessionId}", target.sessionId)
|
|
523
|
+
.replaceAll("{resume}", target.resumeCommand);
|
|
498
524
|
copyToClipboard(cmd).then(
|
|
499
525
|
() => store.flashBanner("info", `📋 Copied resume command (${target.sessionId.slice(0, 8)})`),
|
|
500
526
|
(err) => store.flashBanner("error", `Copy failed: ${err}`),
|
|
@@ -867,33 +893,26 @@ function fmtHm(m: number): string {
|
|
|
867
893
|
}
|
|
868
894
|
|
|
869
895
|
/**
|
|
870
|
-
* Open (resume)
|
|
871
|
-
*
|
|
872
|
-
*
|
|
873
|
-
*
|
|
874
|
-
*
|
|
875
|
-
* 2. `wezterm cli send-text` types `claude --resume <id>` + Enter into it.
|
|
876
|
-
*
|
|
877
|
-
* Running it through the interactive shell (rather than `spawn -- claude …`
|
|
878
|
-
* directly) means `claude` gets your full shell environment — PATH, env vars,
|
|
879
|
-
* any wrapper — which is why the direct form exited 1. And if `claude` still
|
|
880
|
-
* errors, you're left at a live prompt that shows it instead of a vanishing
|
|
881
|
-
* tab. Failures (not inside WezTerm, `wezterm` off PATH) surface as a banner.
|
|
896
|
+
* Open (resume) an agent session in a new tab/window of the terminal tuiboard
|
|
897
|
+
* runs in — tmux, herdr, WezTerm, Windows Terminal, Ghostty, or the OS default
|
|
898
|
+
* — inside the user's shell (see open-session.ts). Config `resume_terminal` /
|
|
899
|
+
* `resume_shell` force them. When nothing can open it, the resume command lands
|
|
900
|
+
* on the clipboard instead. The outcome is kept for the session's detail modal.
|
|
882
901
|
*/
|
|
883
|
-
async function
|
|
884
|
-
|
|
885
|
-
cwd
|
|
886
|
-
sessionId: string,
|
|
887
|
-
): Promise<void> {
|
|
888
|
-
const { spawn, spawnSync } = await import("node:child_process");
|
|
902
|
+
async function openSession(store: TuiStore, session: AgentSession): Promise<void> {
|
|
903
|
+
const { spawn } = await import("node:child_process");
|
|
904
|
+
const { cwd, sessionId } = session;
|
|
889
905
|
|
|
890
906
|
// Custom override (config `resume_command`): an argv array with {cwd} /
|
|
891
|
-
// {sessionId} placeholders, spawned directly (no shell). Lets you
|
|
892
|
-
// personal terminal layout without baking it into the distributed tool.
|
|
907
|
+
// {sessionId} / {resume} placeholders, spawned directly (no shell). Lets you
|
|
908
|
+
// launch a personal terminal layout without baking it into the distributed tool.
|
|
893
909
|
const custom = store.config.resumeCommand;
|
|
894
910
|
if (custom && custom.length > 0) {
|
|
895
911
|
const argv = custom.map((arg) =>
|
|
896
|
-
arg
|
|
912
|
+
arg
|
|
913
|
+
.replaceAll("{cwd}", cwd)
|
|
914
|
+
.replaceAll("{sessionId}", sessionId)
|
|
915
|
+
.replaceAll("{resume}", session.resumeCommand),
|
|
897
916
|
);
|
|
898
917
|
const [cmd, ...rest] = argv;
|
|
899
918
|
try {
|
|
@@ -909,39 +928,83 @@ async function openSessionInWezterm(
|
|
|
909
928
|
store.flashBanner("error", `resume_command failed: ${e.message}`),
|
|
910
929
|
);
|
|
911
930
|
child.unref();
|
|
912
|
-
store.flashBanner("info", `↗ Opening session (${sessionId.slice(0, 8)})`);
|
|
931
|
+
store.flashBanner("info", `↗ Opening session via resume_command (${sessionId.slice(0, 8)})`);
|
|
913
932
|
} catch (e) {
|
|
914
933
|
store.flashBanner("error", `resume_command failed: ${String(e)}`);
|
|
915
934
|
}
|
|
916
935
|
return;
|
|
917
936
|
}
|
|
918
937
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
return;
|
|
927
|
-
}
|
|
928
|
-
if (spawned.status !== 0) {
|
|
929
|
-
store.flashBanner(
|
|
930
|
-
"error",
|
|
931
|
-
`WezTerm spawn failed: ${(spawned.stderr || "").trim() || `exit ${spawned.status}`}`,
|
|
932
|
-
);
|
|
933
|
-
return;
|
|
934
|
-
}
|
|
935
|
-
const paneId = spawned.stdout.trim();
|
|
936
|
-
// Type the resume command into the fresh pane (\r submits, like Enter).
|
|
937
|
-
spawnSync(
|
|
938
|
-
"wezterm",
|
|
939
|
-
["cli", "send-text", "--pane-id", paneId, "--no-paste"],
|
|
940
|
-
{ input: `claude --resume ${sessionId}\r`, encoding: "utf8", windowsHide: true },
|
|
938
|
+
const launchEnv = systemLaunchEnv();
|
|
939
|
+
const forced = store.config.resumeTerminal;
|
|
940
|
+
const launcher = forced === "auto" ? detectLauncher(launchEnv) : forced;
|
|
941
|
+
const fail = async (why: string) => {
|
|
942
|
+
const copied = await copyToClipboard(session.resumeCommand).then(
|
|
943
|
+
() => true,
|
|
944
|
+
() => false,
|
|
941
945
|
);
|
|
942
|
-
|
|
946
|
+
const text = copied ? `${why} — resume command copied, paste it in ${cwd}` : why;
|
|
947
|
+
store.setLastLaunch(sessionId, false, text);
|
|
948
|
+
store.flashBanner(copied ? "warn" : "error", text);
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
if (!launcher) {
|
|
952
|
+
await fail("No supported terminal detected (set resume_terminal)");
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
const plan = planLaunch(
|
|
956
|
+
launcher,
|
|
957
|
+
{ cwd, resume: session.resumeCommand, shell: store.config.resumeShell },
|
|
958
|
+
launchEnv,
|
|
959
|
+
);
|
|
960
|
+
try {
|
|
961
|
+
await runLaunchPlan(plan);
|
|
962
|
+
const text = `↗ Opened session in ${LAUNCHER_NAME[launcher]} (${sessionId.slice(0, 8)})`;
|
|
963
|
+
store.setLastLaunch(sessionId, true, `${text}\n${describePlan(plan)}`);
|
|
964
|
+
store.flashBanner("info", text);
|
|
965
|
+
} catch (e) {
|
|
966
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
967
|
+
await fail(`${LAUNCHER_NAME[launcher]} launch failed: ${msg}`);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* `H` in the Agents zone: focus the herdr pane the session is open in, or
|
|
973
|
+
* resume it in herdr — a new tab in the workspace that holds its project,
|
|
974
|
+
* started with `herdr agent start` (see herdr-open.ts).
|
|
975
|
+
*/
|
|
976
|
+
async function openInHerdr(store: TuiStore, session: AgentSession): Promise<void> {
|
|
977
|
+
const bin = herdrBin();
|
|
978
|
+
const snap = store.agents.herdr();
|
|
979
|
+
const { sessionId } = session;
|
|
980
|
+
if (!bin || !snap) {
|
|
981
|
+
const text = bin ? "herdr isn't running" : "herdr isn't installed (https://herdr.dev)";
|
|
982
|
+
store.setLastLaunch(sessionId, false, text);
|
|
983
|
+
store.flashBanner("warn", text);
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
let steps;
|
|
987
|
+
let done: string;
|
|
988
|
+
if (session.herdr) {
|
|
989
|
+
steps = planHerdrFocus(bin, session);
|
|
990
|
+
done = `↗ ${herdrPlace(session.herdr)}`;
|
|
991
|
+
} else {
|
|
992
|
+
const plan = planHerdrResume(bin, session, snap);
|
|
993
|
+
steps = plan.steps;
|
|
994
|
+
done =
|
|
995
|
+
plan.where.kind === "new"
|
|
996
|
+
? `↗ Resumed in a new herdr workspace "${plan.where.label}"`
|
|
997
|
+
: `↗ Resumed in herdr ${plan.where.label}`;
|
|
998
|
+
store.flashBanner("info", `Starting ${HARNESS[session.provider].name} in herdr…`);
|
|
999
|
+
}
|
|
1000
|
+
try {
|
|
1001
|
+
await runLaunchPlan(steps);
|
|
1002
|
+
store.setLastLaunch(sessionId, true, `${done}\n${describePlan(steps)}`);
|
|
1003
|
+
store.flashBanner("info", `${done} (${sessionId.slice(0, 8)})`);
|
|
943
1004
|
} catch (e) {
|
|
944
|
-
|
|
1005
|
+
const text = `herdr: ${e instanceof Error ? e.message : String(e)}`;
|
|
1006
|
+
store.setLastLaunch(sessionId, false, text);
|
|
1007
|
+
store.flashBanner("error", text);
|
|
945
1008
|
}
|
|
946
1009
|
}
|
|
947
1010
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open agent sessions inside herdr (the `H` key, and Enter for sessions
|
|
3
|
+
* already open there): focus the pane a session lives in, or resume it in a
|
|
4
|
+
* new tab of the workspace that holds its project, started through
|
|
5
|
+
* `herdr agent start` so herdr tracks it as an agent from the first second.
|
|
6
|
+
*
|
|
7
|
+
* Planning is pure; the steps run through `runLaunchPlan`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { basename } from "node:path";
|
|
11
|
+
|
|
12
|
+
import type { LaunchStep } from "~/input/open-session";
|
|
13
|
+
import type { AgentSession } from "~/store/agents";
|
|
14
|
+
import { HERDR_AGENT, samePath, type HerdrSnapshot } from "~/store/herdr";
|
|
15
|
+
|
|
16
|
+
/** Focus the herdr pane a session is open in. */
|
|
17
|
+
export function planHerdrFocus(bin: string, session: AgentSession): LaunchStep[] {
|
|
18
|
+
if (!session.herdr) throw new Error("session isn't open in herdr");
|
|
19
|
+
return [{ cmd: bin, args: ["agent", "focus", session.herdr.paneId] }];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type WorkspaceChoice =
|
|
23
|
+
| { kind: "existing"; workspaceId: string; label: string; why: "cwd" | "name" | "focused" }
|
|
24
|
+
| { kind: "new"; label: string };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where a session should open: the workspace already holding a pane in its
|
|
28
|
+
* directory, else one named like the directory, else the focused one — or a
|
|
29
|
+
* new workspace named after the directory when herdr has none.
|
|
30
|
+
*/
|
|
31
|
+
export function chooseWorkspace(snap: HerdrSnapshot, cwd: string): WorkspaceChoice {
|
|
32
|
+
const label = (id: string) => snap.workspaces.get(id)?.label ?? id;
|
|
33
|
+
const byCwd = snap.panes.find((p) => p.workspaceId && samePath(p.cwd, cwd));
|
|
34
|
+
if (byCwd) return { kind: "existing", workspaceId: byCwd.workspaceId, label: label(byCwd.workspaceId), why: "cwd" };
|
|
35
|
+
const folder = basename(cwd.replaceAll("\\", "/")).toLowerCase();
|
|
36
|
+
for (const [id, w] of snap.workspaces) {
|
|
37
|
+
if (folder && w.label.toLowerCase() === folder) return { kind: "existing", workspaceId: id, label: w.label, why: "name" };
|
|
38
|
+
}
|
|
39
|
+
const focused = snap.focusedWorkspaceId ?? [...snap.workspaces.keys()][0];
|
|
40
|
+
if (focused) return { kind: "existing", workspaceId: focused, label: label(focused), why: "focused" };
|
|
41
|
+
return { kind: "new", label: basename(cwd.replaceAll("\\", "/")) || "agents" };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Pane id from a `tab create` / `workspace create` response. */
|
|
45
|
+
function rootPaneId(out: string): string | undefined {
|
|
46
|
+
try {
|
|
47
|
+
const r = JSON.parse(out)?.result;
|
|
48
|
+
return r?.root_pane?.pane_id ?? r?.pane?.pane_id ?? r?.tab?.root_pane?.pane_id;
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Tab titles: short, single line. */
|
|
55
|
+
function tabLabel(name: string): string {
|
|
56
|
+
const line = name.split("\n")[0]!.trim();
|
|
57
|
+
return line.length > 32 ? `${line.slice(0, 31).trimEnd()}…` : line || "agent";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* herdr agent names: lowercase letter first, then `[a-z0-9_-]`, ≤ 32 chars.
|
|
62
|
+
* Title slug + short session id, so two sessions with the same title differ.
|
|
63
|
+
*/
|
|
64
|
+
export function herdrAgentName(title: string, sessionId: string, fallback: string): string {
|
|
65
|
+
const id = sessionId.toLowerCase().replace(/[^a-z0-9]/g, "").slice(-6);
|
|
66
|
+
const slug = title
|
|
67
|
+
.split("\n")[0]!
|
|
68
|
+
.toLowerCase()
|
|
69
|
+
.normalize("NFKD")
|
|
70
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
71
|
+
.replace(/^[^a-z]+/, "")
|
|
72
|
+
.replace(/-+$/, "")
|
|
73
|
+
.slice(0, 32 - id.length - 1)
|
|
74
|
+
.replace(/-+$/, "");
|
|
75
|
+
return `${slug || fallback}-${id}`.slice(0, 32);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resume a session in herdr: new tab (or workspace) in the session's
|
|
80
|
+
* directory, then start the agent there.
|
|
81
|
+
*
|
|
82
|
+
* POSIX: `herdr agent start --kind <agent> -- <resume args>`, so herdr tracks
|
|
83
|
+
* it from the first second. Windows: herdr launches the agent's executable
|
|
84
|
+
* directly, which fails for npm-installed CLIs (codex, opencode, pi are an
|
|
85
|
+
* extensionless sh shim next to `.cmd`/`.ps1` — "not a valid Win32
|
|
86
|
+
* application"), so the resume command is typed into the pane's shell
|
|
87
|
+
* instead (`herdr pane run`), which resolves it through PATHEXT; herdr still
|
|
88
|
+
* detects the agent from the running process.
|
|
89
|
+
*/
|
|
90
|
+
export function planHerdrResume(
|
|
91
|
+
bin: string,
|
|
92
|
+
session: AgentSession,
|
|
93
|
+
snap: HerdrSnapshot,
|
|
94
|
+
platform: NodeJS.Platform = process.platform,
|
|
95
|
+
): { steps: LaunchStep[]; where: WorkspaceChoice } {
|
|
96
|
+
const where = chooseWorkspace(snap, session.cwd);
|
|
97
|
+
const label = tabLabel(session.displayName);
|
|
98
|
+
const create: LaunchStep =
|
|
99
|
+
where.kind === "existing"
|
|
100
|
+
? {
|
|
101
|
+
cmd: bin,
|
|
102
|
+
args: ["tab", "create", "--workspace", where.workspaceId, "--cwd", session.cwd, "--label", label, "--focus"],
|
|
103
|
+
captureId: rootPaneId,
|
|
104
|
+
undo: { cmd: bin, args: ["pane", "close", "{id}"] },
|
|
105
|
+
}
|
|
106
|
+
: {
|
|
107
|
+
cmd: bin,
|
|
108
|
+
args: ["workspace", "create", "--cwd", session.cwd, "--label", where.label, "--focus"],
|
|
109
|
+
captureId: rootPaneId,
|
|
110
|
+
undo: { cmd: bin, args: ["pane", "close", "{id}"] },
|
|
111
|
+
};
|
|
112
|
+
if (platform === "win32") {
|
|
113
|
+
return {
|
|
114
|
+
steps: [create, { cmd: bin, args: ["pane", "run", "{id}", session.resumeCommand] }],
|
|
115
|
+
where,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const [, ...agentArgs] = session.resumeArgv;
|
|
119
|
+
const start: LaunchStep = {
|
|
120
|
+
cmd: bin,
|
|
121
|
+
args: [
|
|
122
|
+
"agent", "start", herdrAgentName(session.displayName, session.sessionId, HERDR_AGENT[session.provider]),
|
|
123
|
+
"--kind", HERDR_AGENT[session.provider],
|
|
124
|
+
"--pane", "{id}",
|
|
125
|
+
"--timeout", "30000",
|
|
126
|
+
"--", ...agentArgs,
|
|
127
|
+
],
|
|
128
|
+
// herdr waits for the agent to be ready (up to the --timeout above).
|
|
129
|
+
timeoutMs: 40_000,
|
|
130
|
+
};
|
|
131
|
+
return { steps: [create, start], where };
|
|
132
|
+
}
|