terminal-agent-workboard 0.0.1
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/.agent-workboard.example.json +24 -0
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/dist/SlashCommandMenu.js +14 -0
- package/dist/config.js +34 -0
- package/dist/ctrlCBehavior.js +21 -0
- package/dist/globalState.js +44 -0
- package/dist/history.js +148 -0
- package/dist/index.js +87 -0
- package/dist/promptBuffer.js +102 -0
- package/dist/slashCommands.js +29 -0
- package/dist/status.js +230 -0
- package/dist/store.js +32 -0
- package/dist/terminalInput.js +86 -0
- package/dist/tmux.js +42 -0
- package/dist/types.js +1 -0
- package/dist/ui.js +450 -0
- package/dist/workboard.js +194 -0
- package/dist/workboardCommands.js +96 -0
- package/package.json +61 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"tmuxPrefix": "awb",
|
|
3
|
+
"defaultAgent": "codex",
|
|
4
|
+
"agents": {
|
|
5
|
+
"codex": {
|
|
6
|
+
"label": "Codex",
|
|
7
|
+
"command": "codex",
|
|
8
|
+
"resumeCommand": "codex resume {sessionId}"
|
|
9
|
+
},
|
|
10
|
+
"claude": {
|
|
11
|
+
"label": "Claude Code",
|
|
12
|
+
"command": "claude",
|
|
13
|
+
"resumeCommand": "claude --resume {sessionId}"
|
|
14
|
+
},
|
|
15
|
+
"opencode": {
|
|
16
|
+
"label": "OpenCode",
|
|
17
|
+
"command": "opencode"
|
|
18
|
+
},
|
|
19
|
+
"gemini": {
|
|
20
|
+
"label": "Gemini CLI",
|
|
21
|
+
"command": "gemini"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lellansin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# terminal-agent-workboard
|
|
2
|
+
|
|
3
|
+
`workboard` is a tmux-backed Node.js + TypeScript prototype for managing multiple code-agent conversations from one terminal workboard.
|
|
4
|
+
|
|
5
|
+
It can run sessions for tools such as Claude Code, Codex, OpenCode, and Gemini CLI, then group them by inferred state:
|
|
6
|
+
|
|
7
|
+
- Working
|
|
8
|
+
- Needs input
|
|
9
|
+
- Rate limited (agent hit a quota / rate limit modal — switch model or wait for reset)
|
|
10
|
+
- Completed
|
|
11
|
+
- Return pending
|
|
12
|
+
- WIP
|
|
13
|
+
- Human handoff
|
|
14
|
+
- Session switching
|
|
15
|
+
- Agent routing
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
- Node.js 20+
|
|
20
|
+
- tmux
|
|
21
|
+
- Any agent CLI you want to route to, such as `codex`, `claude`, `opencode`, or `gemini`
|
|
22
|
+
|
|
23
|
+
## Tech Stack
|
|
24
|
+
|
|
25
|
+
- Node.js + TypeScript
|
|
26
|
+
- React + Ink for terminal rendering
|
|
27
|
+
- tmux for agent process/session management
|
|
28
|
+
|
|
29
|
+
## Status detection
|
|
30
|
+
|
|
31
|
+
`workboard` polls each tmux pane and infers a status by matching common agent UI patterns. Notably:
|
|
32
|
+
|
|
33
|
+
- `Needs input` — Codex/Claude prompt sitting idle after an assistant reply, or an initialization prompt (e.g. Codex "Update available · press enter to continue") blocks startup.
|
|
34
|
+
- `Rate limited` — codex shows "Approaching rate limits" model-switch modal, or the agent output contains usage-limit / quota-exhausted phrasing. Open the session with `Ctrl+O` to choose a model or wait for the quota window to reset.
|
|
35
|
+
|
|
36
|
+
## History Discovery
|
|
37
|
+
|
|
38
|
+
The default UI shows a bordered `Current Session` panel with the selected session's latest message. Long messages are truncated in the panel; use `Ctrl+O` or `/open` to inspect the full tmux session. History is hidden by default and can be opened with `/history`.
|
|
39
|
+
|
|
40
|
+
The history browser reads JSONL history files only:
|
|
41
|
+
|
|
42
|
+
- `~/.codex/session_index.jsonl`
|
|
43
|
+
- `~/.claude/projects/**/*.jsonl`
|
|
44
|
+
|
|
45
|
+
Markdown files such as Claude plan documents are intentionally ignored.
|
|
46
|
+
|
|
47
|
+
On startup, `workboard` initializes `~/.workboard/state.json`. This global state stores mappings between JSONL history ids and tmux sessions, so `/resume <history-id>` can return to the existing tmux session instead of creating a duplicate.
|
|
48
|
+
|
|
49
|
+
Inside `/history`:
|
|
50
|
+
|
|
51
|
+
- `↑` / `↓` or `j` / `k`: select a history entry
|
|
52
|
+
- `Enter`: resume the selected history session
|
|
53
|
+
- `Esc`: close history and return to the prompt
|
|
54
|
+
|
|
55
|
+
## Setup
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
npm install
|
|
59
|
+
npm run build
|
|
60
|
+
npm link
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Optional config:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
cp .agent-workboard.example.json .agent-workboard.json
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Edit `.agent-workboard.json` if your agent commands differ.
|
|
70
|
+
|
|
71
|
+
## Run
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
npm run dev
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
After `npm link`, use the bin directly:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
workboard
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Useful flags:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
npm run dev -- --demo
|
|
87
|
+
npm run dev -- new codex "review this repository"
|
|
88
|
+
npm run dev -- list
|
|
89
|
+
workboard --demo
|
|
90
|
+
workboard new codex "review this repository"
|
|
91
|
+
workboard list
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Controls
|
|
95
|
+
|
|
96
|
+
- `↑` / `↓` or `j` / `k`: select a session
|
|
97
|
+
- `Enter`: send the bottom input to the selected session
|
|
98
|
+
- `/`: open the slash-command menu
|
|
99
|
+
- `Shift+Enter`: insert a newline in the prompt
|
|
100
|
+
- `←` / `→`: move the prompt cursor
|
|
101
|
+
- `Home` / `End`: move to line start/end
|
|
102
|
+
- `Alt+←` / `Alt+→` or `Ctrl+←` / `Ctrl+→`: move by word
|
|
103
|
+
- `Backspace` / `Delete`: delete before/after the cursor
|
|
104
|
+
- `Ctrl+W`: delete the previous word
|
|
105
|
+
- `Ctrl+K`: delete to the end of the line
|
|
106
|
+
- `Ctrl+N`: mark bottom input as a new-session task
|
|
107
|
+
- `Ctrl+O`: attach to the selected tmux session
|
|
108
|
+
- `Ctrl+X`: stop the selected session
|
|
109
|
+
- `Ctrl+C`: clear the prompt; press again on an empty prompt to quit
|
|
110
|
+
|
|
111
|
+
Commands typed into the input:
|
|
112
|
+
|
|
113
|
+
```text
|
|
114
|
+
/new codex implement login flow
|
|
115
|
+
/new claude inspect failing tests
|
|
116
|
+
/history
|
|
117
|
+
/resume 019dab7d
|
|
118
|
+
/review
|
|
119
|
+
/send payment-migration continue
|
|
120
|
+
/select pr-review
|
|
121
|
+
/open
|
|
122
|
+
/kill
|
|
123
|
+
/refresh
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Without a slash command, pressing `Enter` sends the input to the selected conversation. If nothing is selected, it creates a new session with the default agent.
|
|
127
|
+
If the selected conversation no longer has a running tmux session, plain input creates a new session instead of sending to the dead session.
|
|
128
|
+
|
|
129
|
+
History restore uses a stable tmux session name. `workboard` first checks whether that tmux session already exists; if not, it creates one with the configured `resumeCommand`.
|
|
130
|
+
|
|
131
|
+
Resume command templates can use:
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{
|
|
135
|
+
"resumeCommand": "codex resume {sessionId}"
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Available placeholders: `{sessionId}`, `{sourcePath}`, `{cwd}`, `{title}`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
export function SlashCommandMenu({ items, activeIndex, width, maxVisible = 7 }) {
|
|
4
|
+
if (items.length === 0)
|
|
5
|
+
return null;
|
|
6
|
+
const visibleStart = Math.min(Math.max(0, activeIndex - Math.floor((maxVisible - 1) / 2)), Math.max(0, items.length - maxVisible));
|
|
7
|
+
const visible = items.slice(visibleStart, visibleStart + maxVisible);
|
|
8
|
+
const labelWidth = Math.min(Math.max(14, ...visible.map((item) => item.label.length + 2)), Math.max(14, Math.floor(width / 2)));
|
|
9
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [visible.map((item, index) => {
|
|
10
|
+
const actualIndex = visibleStart + index;
|
|
11
|
+
const active = actualIndex === activeIndex;
|
|
12
|
+
return (_jsxs(Box, { gap: 2, children: [_jsx(Box, { width: labelWidth, children: _jsxs(Text, { color: active ? "cyan" : undefined, bold: active, wrap: "truncate-end", children: [active ? "› " : " ", item.label] }) }), _jsx(Text, { color: active ? "cyan" : "gray", wrap: "truncate-end", children: item.description })] }, item.name));
|
|
13
|
+
}), _jsxs(Text, { color: "gray", children: ["(", activeIndex + 1, "/", items.length, ") \u2191\u2193 select \u00B7 Enter apply \u00B7 Esc close"] })] }));
|
|
14
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
const defaultConfig = {
|
|
4
|
+
tmuxPrefix: "awb",
|
|
5
|
+
defaultAgent: "codex",
|
|
6
|
+
agents: {
|
|
7
|
+
codex: { label: "Codex", command: "codex", resumeCommand: "codex resume {sessionId}" },
|
|
8
|
+
claude: { label: "Claude Code", command: "claude", resumeCommand: "claude --resume {sessionId}" },
|
|
9
|
+
opencode: { label: "OpenCode", command: "opencode" },
|
|
10
|
+
gemini: { label: "Gemini CLI", command: "gemini" }
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
export function loadConfig(cwd) {
|
|
14
|
+
const path = join(cwd, ".agent-workboard.json");
|
|
15
|
+
if (!existsSync(path)) {
|
|
16
|
+
return defaultConfig;
|
|
17
|
+
}
|
|
18
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
19
|
+
return {
|
|
20
|
+
...defaultConfig,
|
|
21
|
+
...parsed,
|
|
22
|
+
agents: {
|
|
23
|
+
...defaultConfig.agents,
|
|
24
|
+
...(parsed.agents ?? {})
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function ensureConfigExample(cwd) {
|
|
29
|
+
const path = join(cwd, ".agent-workboard.example.json");
|
|
30
|
+
if (existsSync(path))
|
|
31
|
+
return;
|
|
32
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
33
|
+
writeFileSync(path, `${JSON.stringify(defaultConfig, null, 2)}\n`);
|
|
34
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function decideCtrlC(inputText, pendingExitAt, now, timeoutMs = 2000) {
|
|
2
|
+
if (inputText.length > 0) {
|
|
3
|
+
return {
|
|
4
|
+
action: "clearPrompt",
|
|
5
|
+
message: "prompt cleared; press ctrl+c again to quit",
|
|
6
|
+
pendingExitAt: null
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
if (pendingExitAt !== null && now - pendingExitAt <= timeoutMs) {
|
|
10
|
+
return {
|
|
11
|
+
action: "exit",
|
|
12
|
+
message: "exiting",
|
|
13
|
+
pendingExitAt: null
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
action: "promptExit",
|
|
18
|
+
message: "press ctrl+c again to quit",
|
|
19
|
+
pendingExitAt: now
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
export class GlobalStateStore {
|
|
5
|
+
dir;
|
|
6
|
+
path;
|
|
7
|
+
constructor(dir = defaultWorkboardHome()) {
|
|
8
|
+
this.dir = dir;
|
|
9
|
+
this.path = join(dir, "state.json");
|
|
10
|
+
}
|
|
11
|
+
init() {
|
|
12
|
+
mkdirSync(this.dir, { recursive: true });
|
|
13
|
+
if (!existsSync(this.path)) {
|
|
14
|
+
this.writeMappings([]);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
readMappings() {
|
|
18
|
+
if (!existsSync(this.path))
|
|
19
|
+
return [];
|
|
20
|
+
const parsed = JSON.parse(readFileSync(this.path, "utf8"));
|
|
21
|
+
return parsed.mappings ?? [];
|
|
22
|
+
}
|
|
23
|
+
findByHistoryId(historyId) {
|
|
24
|
+
return this.readMappings().find((mapping) => mapping.historyId === historyId);
|
|
25
|
+
}
|
|
26
|
+
upsertMapping(mapping) {
|
|
27
|
+
const mappings = this.readMappings();
|
|
28
|
+
const index = mappings.findIndex((candidate) => candidate.historyId === mapping.historyId);
|
|
29
|
+
if (index >= 0) {
|
|
30
|
+
mappings[index] = mapping;
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
mappings.push(mapping);
|
|
34
|
+
}
|
|
35
|
+
this.writeMappings(mappings);
|
|
36
|
+
}
|
|
37
|
+
writeMappings(mappings) {
|
|
38
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
39
|
+
writeFileSync(this.path, `${JSON.stringify({ mappings }, null, 2)}\n`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function defaultWorkboardHome() {
|
|
43
|
+
return process.env.WORKBOARD_HOME || join(homedir(), ".workboard");
|
|
44
|
+
}
|
package/dist/history.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { ageLabel } from "./status.js";
|
|
5
|
+
export function discoverHistory(limit = 12) {
|
|
6
|
+
return [...discoverCodexHistory(limit), ...discoverClaudeHistory(limit)]
|
|
7
|
+
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
|
8
|
+
.slice(0, limit);
|
|
9
|
+
}
|
|
10
|
+
function discoverCodexHistory(limit) {
|
|
11
|
+
const path = join(homedir(), ".codex", "session_index.jsonl");
|
|
12
|
+
if (!existsSync(path))
|
|
13
|
+
return [];
|
|
14
|
+
return readJsonl(path)
|
|
15
|
+
.map((item) => {
|
|
16
|
+
const id = asString(item.id) ?? basename(path);
|
|
17
|
+
const updatedAt = asString(item.updated_at) ?? new Date(statSync(path).mtimeMs).toISOString();
|
|
18
|
+
const title = asString(item.thread_name) ?? id;
|
|
19
|
+
return toEntry({ id, agent: "codex", title, updatedAt, sourcePath: path });
|
|
20
|
+
})
|
|
21
|
+
.filter((entry) => Boolean(entry))
|
|
22
|
+
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
|
23
|
+
.slice(0, limit);
|
|
24
|
+
}
|
|
25
|
+
function discoverClaudeHistory(limit) {
|
|
26
|
+
const root = join(homedir(), ".claude", "projects");
|
|
27
|
+
if (!existsSync(root))
|
|
28
|
+
return [];
|
|
29
|
+
return findJsonlFiles(root, 5)
|
|
30
|
+
.filter((path) => !path.includes("/subagents/"))
|
|
31
|
+
.map((path) => parseClaudeJsonl(path))
|
|
32
|
+
.filter((entry) => Boolean(entry))
|
|
33
|
+
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
|
34
|
+
.slice(0, limit);
|
|
35
|
+
}
|
|
36
|
+
function parseClaudeJsonl(path) {
|
|
37
|
+
const fallbackId = basename(path, ".jsonl");
|
|
38
|
+
const fallbackTime = new Date(statSync(path).mtimeMs).toISOString();
|
|
39
|
+
let cwd;
|
|
40
|
+
let updatedAt = fallbackTime;
|
|
41
|
+
let sessionId = fallbackId;
|
|
42
|
+
let title = "";
|
|
43
|
+
for (const item of readJsonl(path, 120)) {
|
|
44
|
+
const timestamp = asString(item.timestamp);
|
|
45
|
+
if (timestamp)
|
|
46
|
+
updatedAt = timestamp;
|
|
47
|
+
cwd = asString(item.cwd) ?? cwd;
|
|
48
|
+
sessionId = asString(item.sessionId) ?? sessionId;
|
|
49
|
+
if (item.type === "user") {
|
|
50
|
+
const message = asObject(item.message);
|
|
51
|
+
const content = extractText(message?.content);
|
|
52
|
+
if (content && !isEnvironmentContext(content)) {
|
|
53
|
+
title = content;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!title) {
|
|
59
|
+
title = fallbackId;
|
|
60
|
+
}
|
|
61
|
+
return toEntry({
|
|
62
|
+
id: sessionId,
|
|
63
|
+
agent: "claude",
|
|
64
|
+
title,
|
|
65
|
+
cwd: cwd ?? decodeClaudeProjectPath(dirname(path)),
|
|
66
|
+
updatedAt,
|
|
67
|
+
sourcePath: path
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function findJsonlFiles(root, maxDepth) {
|
|
71
|
+
const files = [];
|
|
72
|
+
const visit = (dir, depth) => {
|
|
73
|
+
if (depth > maxDepth)
|
|
74
|
+
return;
|
|
75
|
+
for (const entry of safeReadDir(dir)) {
|
|
76
|
+
const path = join(dir, entry.name);
|
|
77
|
+
if (entry.isDirectory()) {
|
|
78
|
+
visit(path, depth + 1);
|
|
79
|
+
}
|
|
80
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
81
|
+
files.push(path);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
visit(root, 0);
|
|
86
|
+
return files
|
|
87
|
+
.map((path) => ({ path, mtimeMs: statSync(path).mtimeMs }))
|
|
88
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
89
|
+
.slice(0, 200)
|
|
90
|
+
.map((item) => item.path);
|
|
91
|
+
}
|
|
92
|
+
function readJsonl(path, maxLines = Number.POSITIVE_INFINITY) {
|
|
93
|
+
return readFileSync(path, "utf8")
|
|
94
|
+
.split(/\r?\n/)
|
|
95
|
+
.slice(0, maxLines)
|
|
96
|
+
.map((line) => line.trim())
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
.map((line) => {
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(line);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
.filter((item) => Boolean(item));
|
|
107
|
+
}
|
|
108
|
+
function safeReadDir(path) {
|
|
109
|
+
try {
|
|
110
|
+
return readdirSync(path, { withFileTypes: true, encoding: "utf8" });
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function toEntry(input) {
|
|
117
|
+
const title = input.title.trim().replace(/\s+/g, " ").slice(0, 120);
|
|
118
|
+
if (!title)
|
|
119
|
+
return null;
|
|
120
|
+
return {
|
|
121
|
+
...input,
|
|
122
|
+
title,
|
|
123
|
+
ageLabel: ageLabel(input.updatedAt)
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function extractText(value) {
|
|
127
|
+
if (typeof value === "string")
|
|
128
|
+
return value;
|
|
129
|
+
if (Array.isArray(value)) {
|
|
130
|
+
return value.map((item) => extractText(asObject(item)?.text)).filter(Boolean).join(" ");
|
|
131
|
+
}
|
|
132
|
+
return "";
|
|
133
|
+
}
|
|
134
|
+
function decodeClaudeProjectPath(path) {
|
|
135
|
+
const encoded = basename(path);
|
|
136
|
+
if (!encoded.startsWith("-"))
|
|
137
|
+
return undefined;
|
|
138
|
+
return encoded.replace(/-/g, "/");
|
|
139
|
+
}
|
|
140
|
+
function isEnvironmentContext(text) {
|
|
141
|
+
return text.trim().startsWith("<environment_context>");
|
|
142
|
+
}
|
|
143
|
+
function asObject(value) {
|
|
144
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
145
|
+
}
|
|
146
|
+
function asString(value) {
|
|
147
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
148
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { loadConfig } from "./config.js";
|
|
3
|
+
import { SessionStore } from "./store.js";
|
|
4
|
+
import { Tmux } from "./tmux.js";
|
|
5
|
+
import { Workboard } from "./workboard.js";
|
|
6
|
+
import { GlobalStateStore } from "./globalState.js";
|
|
7
|
+
import React from "react";
|
|
8
|
+
import { render } from "ink";
|
|
9
|
+
import { WorkboardApp } from "./ui.js";
|
|
10
|
+
const cwd = process.cwd();
|
|
11
|
+
const config = loadConfig(cwd);
|
|
12
|
+
const store = new SessionStore(cwd);
|
|
13
|
+
const globalState = new GlobalStateStore();
|
|
14
|
+
const tmux = new Tmux();
|
|
15
|
+
const workboard = new Workboard(cwd, config, store, tmux, globalState);
|
|
16
|
+
async function main() {
|
|
17
|
+
globalState.init();
|
|
18
|
+
if (!tmux.available()) {
|
|
19
|
+
throw new Error("tmux is required but was not found on PATH.");
|
|
20
|
+
}
|
|
21
|
+
const [command, ...args] = process.argv.slice(2);
|
|
22
|
+
if (command === "new") {
|
|
23
|
+
const [agent, ...taskParts] = args;
|
|
24
|
+
const task = taskParts.join(" ");
|
|
25
|
+
if (!task)
|
|
26
|
+
throw new Error("Usage: workboard new <agent> <task>");
|
|
27
|
+
const session = workboard.create(agent, task);
|
|
28
|
+
console.log(`created ${session.id} (${session.tmuxSession})`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (command === "send") {
|
|
32
|
+
const [id, ...inputParts] = args;
|
|
33
|
+
if (!id || inputParts.length === 0)
|
|
34
|
+
throw new Error("Usage: workboard send <session> <input>");
|
|
35
|
+
workboard.send(id, inputParts.join(" "));
|
|
36
|
+
console.log(`sent to ${id}`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (command === "list") {
|
|
40
|
+
for (const session of workboard.list()) {
|
|
41
|
+
console.log(`${session.status.padEnd(17)} ${session.id.padEnd(32)} ${session.agent.padEnd(10)} ${session.summary}`);
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (command === "open") {
|
|
46
|
+
const [id] = args;
|
|
47
|
+
if (!id)
|
|
48
|
+
throw new Error("Usage: workboard open <session>");
|
|
49
|
+
workboard.attach(id);
|
|
50
|
+
}
|
|
51
|
+
if (command === "--demo") {
|
|
52
|
+
seedDemo(store, config.tmuxPrefix);
|
|
53
|
+
}
|
|
54
|
+
if (!process.stdin.isTTY) {
|
|
55
|
+
throw new Error("workboard requires an interactive terminal (TTY).");
|
|
56
|
+
}
|
|
57
|
+
render(React.createElement(WorkboardApp, { workboard, config }), { exitOnCtrlC: false });
|
|
58
|
+
}
|
|
59
|
+
function seedDemo(store, prefix) {
|
|
60
|
+
const now = new Date();
|
|
61
|
+
store.write([
|
|
62
|
+
demo("dark-mode", "codex", "system theme vs explicit toggle - your call", "Needs input", 4, prefix, now),
|
|
63
|
+
demo("release-notes", "claude", "draft ready - which feature leads?", "Needs input", 11, prefix, now),
|
|
64
|
+
demo("load-test", "gemini", "-> to return", "Return pending", 3, prefix, now),
|
|
65
|
+
demo("pr-review", "codex", "-> to return", "Working", 0, prefix, now),
|
|
66
|
+
demo("perf-audit", "opencode", "events_org_ts index live - p95 38ms", "Working", 7, prefix, now),
|
|
67
|
+
demo("payment-migration", "claude", "porting billing to the new processor - 12/14", "WIP", 2, prefix, now),
|
|
68
|
+
demo("onboarding-copy", "gemini", "rewriting empty-state copy across 6 screens", "Working", 1, prefix, now),
|
|
69
|
+
demo("test-coverage", "codex", "billing/ from 61% -> 92% - PR #408 merged", "Completed", 9, prefix, now)
|
|
70
|
+
]);
|
|
71
|
+
}
|
|
72
|
+
function demo(id, agent, title, statusOverride, ageMinutes, prefix, now) {
|
|
73
|
+
const updatedAt = new Date(now.getTime() - ageMinutes * 60_000).toISOString();
|
|
74
|
+
return {
|
|
75
|
+
id,
|
|
76
|
+
title,
|
|
77
|
+
agent,
|
|
78
|
+
tmuxSession: `${prefix}-${id}`,
|
|
79
|
+
createdAt: updatedAt,
|
|
80
|
+
updatedAt,
|
|
81
|
+
statusOverride
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
main().catch((error) => {
|
|
85
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export const EMPTY_BUFFER = { text: "", cursor: 0 };
|
|
2
|
+
export function insertText(state, value) {
|
|
3
|
+
if (!value)
|
|
4
|
+
return state;
|
|
5
|
+
const text = state.text.slice(0, state.cursor) + value + state.text.slice(state.cursor);
|
|
6
|
+
return { text, cursor: state.cursor + value.length };
|
|
7
|
+
}
|
|
8
|
+
export function backspace(state) {
|
|
9
|
+
if (state.cursor === 0)
|
|
10
|
+
return state;
|
|
11
|
+
return {
|
|
12
|
+
text: state.text.slice(0, state.cursor - 1) + state.text.slice(state.cursor),
|
|
13
|
+
cursor: state.cursor - 1
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function deleteForward(state) {
|
|
17
|
+
if (state.cursor >= state.text.length)
|
|
18
|
+
return state;
|
|
19
|
+
return {
|
|
20
|
+
text: state.text.slice(0, state.cursor) + state.text.slice(state.cursor + 1),
|
|
21
|
+
cursor: state.cursor
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function deleteWordBefore(state) {
|
|
25
|
+
const end = state.cursor;
|
|
26
|
+
let start = end;
|
|
27
|
+
while (start > 0 && /\s/.test(state.text[start - 1] ?? ""))
|
|
28
|
+
start--;
|
|
29
|
+
while (start > 0 && !/\s/.test(state.text[start - 1] ?? ""))
|
|
30
|
+
start--;
|
|
31
|
+
return {
|
|
32
|
+
text: state.text.slice(0, start) + state.text.slice(end),
|
|
33
|
+
cursor: start
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function moveLeft(state) {
|
|
37
|
+
return state.cursor === 0 ? state : { ...state, cursor: state.cursor - 1 };
|
|
38
|
+
}
|
|
39
|
+
export function moveRight(state) {
|
|
40
|
+
return state.cursor >= state.text.length ? state : { ...state, cursor: state.cursor + 1 };
|
|
41
|
+
}
|
|
42
|
+
export function moveWordLeft(state) {
|
|
43
|
+
let cursor = state.cursor;
|
|
44
|
+
while (cursor > 0 && /\s/.test(state.text[cursor - 1] ?? ""))
|
|
45
|
+
cursor--;
|
|
46
|
+
while (cursor > 0 && !/\s/.test(state.text[cursor - 1] ?? ""))
|
|
47
|
+
cursor--;
|
|
48
|
+
return { ...state, cursor };
|
|
49
|
+
}
|
|
50
|
+
export function moveWordRight(state) {
|
|
51
|
+
let cursor = state.cursor;
|
|
52
|
+
while (cursor < state.text.length && /\s/.test(state.text[cursor] ?? ""))
|
|
53
|
+
cursor++;
|
|
54
|
+
while (cursor < state.text.length && !/\s/.test(state.text[cursor] ?? ""))
|
|
55
|
+
cursor++;
|
|
56
|
+
return { ...state, cursor };
|
|
57
|
+
}
|
|
58
|
+
export function moveLineStart(state) {
|
|
59
|
+
return { ...state, cursor: locate(state).lineStart };
|
|
60
|
+
}
|
|
61
|
+
export function moveLineEnd(state) {
|
|
62
|
+
return { ...state, cursor: locate(state).lineEnd };
|
|
63
|
+
}
|
|
64
|
+
export function moveUp(state) {
|
|
65
|
+
const { column, lineStart } = locate(state);
|
|
66
|
+
if (lineStart === 0)
|
|
67
|
+
return { ...state, cursor: 0 };
|
|
68
|
+
const previousLineEnd = lineStart - 1;
|
|
69
|
+
const previousLineStart = state.text.lastIndexOf("\n", previousLineEnd - 1) + 1;
|
|
70
|
+
return { ...state, cursor: previousLineStart + Math.min(column, previousLineEnd - previousLineStart) };
|
|
71
|
+
}
|
|
72
|
+
export function moveDown(state) {
|
|
73
|
+
const { column, lineEnd } = locate(state);
|
|
74
|
+
if (lineEnd >= state.text.length)
|
|
75
|
+
return { ...state, cursor: state.text.length };
|
|
76
|
+
const nextLineStart = lineEnd + 1;
|
|
77
|
+
const nextLineNewline = state.text.indexOf("\n", nextLineStart);
|
|
78
|
+
const nextLineEnd = nextLineNewline === -1 ? state.text.length : nextLineNewline;
|
|
79
|
+
return { ...state, cursor: nextLineStart + Math.min(column, nextLineEnd - nextLineStart) };
|
|
80
|
+
}
|
|
81
|
+
export function killLine(state) {
|
|
82
|
+
const { lineEnd } = locate(state);
|
|
83
|
+
return { text: state.text.slice(0, state.cursor) + state.text.slice(lineEnd), cursor: state.cursor };
|
|
84
|
+
}
|
|
85
|
+
export function getCurrentSlashToken(state) {
|
|
86
|
+
const beforeCursor = state.text.slice(0, state.cursor);
|
|
87
|
+
const line = beforeCursor.slice(beforeCursor.lastIndexOf("\n") + 1);
|
|
88
|
+
if (!line.startsWith("/") || /\s/.test(line))
|
|
89
|
+
return null;
|
|
90
|
+
return line;
|
|
91
|
+
}
|
|
92
|
+
function locate(state) {
|
|
93
|
+
const before = state.text.slice(0, state.cursor);
|
|
94
|
+
const lineStart = before.lastIndexOf("\n") + 1;
|
|
95
|
+
const after = state.text.slice(state.cursor);
|
|
96
|
+
const nextNewline = after.indexOf("\n");
|
|
97
|
+
return {
|
|
98
|
+
column: state.cursor - lineStart,
|
|
99
|
+
lineStart,
|
|
100
|
+
lineEnd: nextNewline === -1 ? state.text.length : state.cursor + nextNewline
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function buildSlashCommands(agents) {
|
|
2
|
+
return [
|
|
3
|
+
{ kind: "new", name: "new", label: "/new", description: `Create a session: /new [${agents.join("|")}] <task>`, insertText: "/new " },
|
|
4
|
+
{ kind: "history", name: "history", label: "/history", description: "Browse discovered JSONL history sessions" },
|
|
5
|
+
{ kind: "resume", name: "resume", label: "/resume", description: "Restore a JSONL history session: /resume <id-prefix>", insertText: "/resume " },
|
|
6
|
+
{ kind: "review", name: "review", label: "/review", description: "Preview selected tmux session pane", insertText: "/review" },
|
|
7
|
+
{ kind: "send", name: "send", label: "/send", description: "Send input to a session: /send <id> <text>", insertText: "/send " },
|
|
8
|
+
{ kind: "select", name: "select", label: "/select", description: "Select a session by id/title", insertText: "/select " },
|
|
9
|
+
{ kind: "open", name: "open", label: "/open", description: "Attach to selected tmux session" },
|
|
10
|
+
{ kind: "kill", name: "kill", label: "/kill", description: "Stop selected tmux session" },
|
|
11
|
+
{ kind: "status", name: "status", label: "/status", description: "Override status: /status <id> <status>", insertText: "/status " },
|
|
12
|
+
{ kind: "refresh", name: "refresh", label: "/refresh", description: "Reload tmux/session state" },
|
|
13
|
+
{ kind: "help", name: "help", label: "/help", description: "Show available slash commands" },
|
|
14
|
+
{ kind: "quit", name: "quit", label: "/quit", description: "Exit workboard" }
|
|
15
|
+
];
|
|
16
|
+
}
|
|
17
|
+
export function filterSlashCommands(items, token) {
|
|
18
|
+
if (!token.startsWith("/"))
|
|
19
|
+
return [];
|
|
20
|
+
const query = token.slice(1).toLowerCase();
|
|
21
|
+
if (!query)
|
|
22
|
+
return items;
|
|
23
|
+
return items.filter((item) => item.label.slice(1).toLowerCase().includes(query) || item.description.toLowerCase().includes(query));
|
|
24
|
+
}
|
|
25
|
+
export function isExactSlashCommand(input, item) {
|
|
26
|
+
if (!item)
|
|
27
|
+
return false;
|
|
28
|
+
return input.trim() === item.label;
|
|
29
|
+
}
|