tmux-ide 1.2.1 → 2.0.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/bin/cli.js +4 -216
- package/bin/cli.ts +536 -0
- package/package.json +33 -14
- package/src/__tests__/support.ts +186 -0
- package/src/{attach.js → attach.ts} +7 -4
- package/src/cli.test.ts +314 -0
- package/src/command-center/discovery.test.ts +265 -0
- package/src/command-center/discovery.ts +296 -0
- package/src/command-center/index.ts +31 -0
- package/src/command-center/schemas.ts +57 -0
- package/src/command-center/server.test.ts +818 -0
- package/src/command-center/server.ts +1426 -0
- package/src/config-cli.test.ts +86 -0
- package/src/config.test.ts +205 -0
- package/src/config.ts +313 -0
- package/src/detect.test.ts +142 -0
- package/src/{detect.js → detect.ts} +31 -15
- package/src/dispatch.ts +102 -0
- package/src/doctor.test.ts +119 -0
- package/src/doctor.ts +167 -0
- package/src/init.test.ts +101 -0
- package/src/init.ts +144 -0
- package/src/inspect.test.ts +67 -0
- package/src/{inspect.js → inspect.ts} +95 -10
- package/src/integration.test.ts.skip +276 -0
- package/src/js-yaml.d.ts +10 -0
- package/src/launch.test.ts +222 -0
- package/src/launch.ts +494 -0
- package/src/lib/auth/auth-service.test.ts +178 -0
- package/src/lib/auth/auth-service.ts +227 -0
- package/src/lib/auth/middleware.test.ts +119 -0
- package/src/lib/auth/middleware.ts +56 -0
- package/src/lib/auth/types.ts +13 -0
- package/src/lib/authorship.test.ts +242 -0
- package/src/lib/authorship.ts +280 -0
- package/src/lib/cast/recorder.test.ts +127 -0
- package/src/lib/cast/recorder.ts +148 -0
- package/src/lib/daemon-watchdog.ts +102 -0
- package/src/lib/daemon.ts +417 -0
- package/src/lib/dot-path.test.ts +66 -0
- package/src/lib/dot-path.ts +17 -0
- package/src/lib/errors.ts +44 -0
- package/src/lib/event-log.test.ts +189 -0
- package/src/lib/event-log.ts +237 -0
- package/src/lib/github-pr.ts +182 -0
- package/src/lib/hq/client.test.ts +154 -0
- package/src/lib/hq/client.ts +142 -0
- package/src/lib/hq/mdns.test.ts +54 -0
- package/src/lib/hq/mdns.ts +88 -0
- package/src/lib/hq/registry.test.ts +237 -0
- package/src/lib/hq/registry.ts +145 -0
- package/src/lib/hq/types.ts +44 -0
- package/src/lib/ipc/socket-protocol.test.ts +126 -0
- package/src/lib/ipc/socket-protocol.ts +324 -0
- package/src/lib/launch-plan.test.ts +119 -0
- package/src/lib/launch-plan.ts +90 -0
- package/src/lib/log.test.ts +39 -0
- package/src/lib/log.ts +71 -0
- package/src/lib/metrics.test.ts +213 -0
- package/src/lib/metrics.ts +345 -0
- package/src/lib/orchestrator.test.ts +2358 -0
- package/src/lib/orchestrator.ts +1792 -0
- package/src/lib/{output.js → output.ts} +18 -10
- package/src/lib/plan-store.test.ts +148 -0
- package/src/lib/plan-store.ts +131 -0
- package/src/lib/research.test.ts +208 -0
- package/src/lib/research.ts +475 -0
- package/src/lib/round-trip.test.ts +341 -0
- package/src/lib/session-monitor.test.ts +152 -0
- package/src/lib/session-monitor.ts +282 -0
- package/src/lib/session-options.test.ts +146 -0
- package/src/lib/{session-options.js → session-options.ts} +11 -6
- package/src/lib/shell.test.ts +20 -0
- package/src/lib/shell.ts +8 -0
- package/src/lib/sizes.test.ts +73 -0
- package/src/lib/{sizes.js → sizes.ts} +4 -4
- package/src/lib/skill-registry.test.ts +134 -0
- package/src/lib/skill-registry.ts +86 -0
- package/src/lib/slugify.test.ts +35 -0
- package/src/lib/slugify.ts +10 -0
- package/src/lib/task-store.ts +352 -0
- package/src/lib/tmux.test.ts +500 -0
- package/src/lib/tmux.ts +372 -0
- package/src/lib/token-tracker.test.ts +102 -0
- package/src/lib/token-tracker.ts +70 -0
- package/src/lib/tunnels/cloudflare.test.ts +73 -0
- package/src/lib/tunnels/cloudflare.ts +254 -0
- package/src/lib/tunnels/manager.test.ts +63 -0
- package/src/lib/tunnels/manager.ts +132 -0
- package/src/lib/tunnels/ngrok.test.ts +94 -0
- package/src/lib/tunnels/ngrok.ts +261 -0
- package/src/lib/tunnels/tailscale.test.ts +103 -0
- package/src/lib/tunnels/tailscale.ts +1048 -0
- package/src/lib/tunnels/types.ts +44 -0
- package/src/lib/validation.test.ts +289 -0
- package/src/lib/validation.ts +123 -0
- package/src/lib/webhook.ts +51 -0
- package/src/lib/workflow-store.ts +209 -0
- package/src/lib/ws-v3/protocol.test.ts +127 -0
- package/src/lib/ws-v3/protocol.ts +160 -0
- package/src/lib/yaml-io.test.ts +125 -0
- package/src/lib/{yaml-io.js → yaml-io.ts} +5 -4
- package/src/ls.test.ts +76 -0
- package/src/{ls.js → ls.ts} +4 -4
- package/src/metrics-cli.ts +177 -0
- package/src/notify.ts +85 -0
- package/src/orchestrator-status.ts +228 -0
- package/src/plan.ts +117 -0
- package/src/postinstall.test.ts +79 -0
- package/src/remote.test.ts +115 -0
- package/src/remote.ts +148 -0
- package/src/restart.ts +24 -0
- package/src/schemas/domain.ts +422 -0
- package/src/schemas/ide-config.ts +148 -0
- package/src/schemas/index.ts +53 -0
- package/src/send.test.ts +70 -0
- package/src/send.ts +181 -0
- package/src/skill.test.ts +114 -0
- package/src/skill.ts +138 -0
- package/src/status.ts +66 -0
- package/src/stop.test.ts +176 -0
- package/src/stop.ts +47 -0
- package/src/task.test.ts +1466 -0
- package/src/task.ts +1143 -0
- package/src/tunnel.test.ts +80 -0
- package/src/tunnel.ts +133 -0
- package/src/types.ts +15 -0
- package/src/ui/index.ts +32 -0
- package/src/ui/terminal/index.ts +9 -0
- package/src/ui/types.ts +91 -0
- package/src/ui/web/base.css +80 -0
- package/src/ui/web/components/Box.tsx +59 -0
- package/src/ui/web/components/Input.tsx +32 -0
- package/src/ui/web/components/ScrollBox.tsx +60 -0
- package/src/ui/web/components/Text.tsx +28 -0
- package/src/ui/web/hooks.ts +106 -0
- package/src/ui/web/index.ts +27 -0
- package/src/ui/web/render.ts +77 -0
- package/src/ui/web/utils/color.test.ts +93 -0
- package/src/ui/web/utils/color.ts +27 -0
- package/src/validate.test.ts +385 -0
- package/src/validate.ts +217 -0
- package/src/widgets/changes/index.tsx +576 -0
- package/src/widgets/config/index.tsx +440 -0
- package/src/widgets/costs/index.tsx +216 -0
- package/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/src/widgets/explorer/footer.tsx +20 -0
- package/src/widgets/explorer/header.tsx +23 -0
- package/src/widgets/explorer/index.tsx +408 -0
- package/src/widgets/explorer/tree-model.test.ts +214 -0
- package/src/widgets/explorer/tree-model.ts +103 -0
- package/src/widgets/explorer/tree.tsx +156 -0
- package/src/widgets/lib/config-model.ts +116 -0
- package/src/widgets/lib/files.test.ts +103 -0
- package/src/widgets/lib/files.ts +88 -0
- package/src/widgets/lib/git.test.ts +151 -0
- package/src/widgets/lib/git.ts +88 -0
- package/src/widgets/lib/pane-comms.test.ts +178 -0
- package/src/widgets/lib/pane-comms.ts +192 -0
- package/src/widgets/lib/theme.ts +151 -0
- package/src/widgets/lib/watcher.ts +50 -0
- package/src/widgets/mission-control/activity-feed.tsx +59 -0
- package/src/widgets/mission-control/agent-panel.tsx +132 -0
- package/src/widgets/mission-control/command-bar.tsx +63 -0
- package/src/widgets/mission-control/index.tsx +751 -0
- package/src/widgets/mission-control/task-panel.tsx +238 -0
- package/src/widgets/preview/index.tsx +415 -0
- package/src/widgets/resolve.ts +40 -0
- package/src/widgets/setup/agent-naming.tsx +112 -0
- package/src/widgets/setup/config-tree.tsx +238 -0
- package/src/widgets/setup/detect-panel.tsx +72 -0
- package/src/widgets/setup/field-editor.tsx +265 -0
- package/src/widgets/setup/footer.tsx +107 -0
- package/src/widgets/setup/index.tsx +340 -0
- package/src/widgets/setup/layout-picker.tsx +96 -0
- package/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/src/widgets/setup/review-panel.tsx +140 -0
- package/src/widgets/setup/setup-model.test.ts +303 -0
- package/src/widgets/setup/setup-model.ts +188 -0
- package/src/widgets/tasks/index.tsx +129 -0
- package/src/widgets/tasks/task-detail.tsx +309 -0
- package/src/widgets/tasks/task-form.tsx +193 -0
- package/src/widgets/tasks/task-list.tsx +205 -0
- package/src/widgets/tasks/task-model.test.ts +473 -0
- package/src/widgets/tasks/task-model.ts +157 -0
- package/templates/AGENTS.md +20 -0
- package/templates/missions.yml +41 -0
- package/templates/skills/backend.md +30 -0
- package/templates/skills/frontend.md +30 -0
- package/templates/skills/general-worker.md +30 -0
- package/templates/skills/researcher.md +69 -0
- package/templates/skills/reviewer.md +30 -0
- package/src/config.js +0 -341
- package/src/doctor.js +0 -91
- package/src/init.js +0 -73
- package/src/launch.js +0 -245
- package/src/lib/dot-path.js +0 -16
- package/src/lib/errors.js +0 -35
- package/src/lib/launch-plan.js +0 -49
- package/src/lib/session-monitor.js +0 -179
- package/src/lib/tmux.js +0 -237
- package/src/restart.js +0 -16
- package/src/status.js +0 -35
- package/src/stop.js +0 -25
- package/src/validate.js +0 -154
package/src/send.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { resolve, join } from "node:path";
|
|
3
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { getSessionName } from "./lib/yaml-io.ts";
|
|
5
|
+
import { getSessionState } from "./lib/tmux.ts";
|
|
6
|
+
import {
|
|
7
|
+
listSessionPanes,
|
|
8
|
+
sendCommand,
|
|
9
|
+
sendText,
|
|
10
|
+
getPaneBusyStatus,
|
|
11
|
+
type PaneInfo,
|
|
12
|
+
type PaneBusyStatus,
|
|
13
|
+
} from "./widgets/lib/pane-comms.ts";
|
|
14
|
+
import { appendEvent } from "./lib/event-log.ts";
|
|
15
|
+
import { IdeError } from "./lib/errors.ts";
|
|
16
|
+
|
|
17
|
+
export const LONG_MESSAGE_THRESHOLD = 150;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Write a long message to a dispatch file and return the short trigger command.
|
|
21
|
+
* Returns null if message is short enough to send directly.
|
|
22
|
+
*/
|
|
23
|
+
export function writeDispatchFile(
|
|
24
|
+
dir: string,
|
|
25
|
+
paneId: string,
|
|
26
|
+
message: string,
|
|
27
|
+
): { filePath: string; triggerCmd: string } | null {
|
|
28
|
+
if (message.length <= LONG_MESSAGE_THRESHOLD) return null;
|
|
29
|
+
const dispatchDir = join(dir, ".tasks", "dispatch");
|
|
30
|
+
if (!existsSync(dispatchDir)) mkdirSync(dispatchDir, { recursive: true });
|
|
31
|
+
const paneSlug = paneId.replace("%", "");
|
|
32
|
+
const filename = `send-${paneSlug}-${Date.now()}-${randomUUID().slice(0, 8)}.md`;
|
|
33
|
+
const filePath = join(dispatchDir, filename);
|
|
34
|
+
writeFileSync(filePath, message);
|
|
35
|
+
return { filePath, triggerCmd: `Read and execute: .tasks/dispatch/${filename}` };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface SendOptions {
|
|
39
|
+
json?: boolean;
|
|
40
|
+
to?: string;
|
|
41
|
+
message?: string;
|
|
42
|
+
noEnter?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve a target string to a pane. Priority:
|
|
47
|
+
* 1. Exact pane ID (%N)
|
|
48
|
+
* 2. @ide_name match
|
|
49
|
+
* 3. Exact title match
|
|
50
|
+
* 4. Role match (lead, teammate, planner)
|
|
51
|
+
* 5. Case-insensitive partial title match
|
|
52
|
+
*/
|
|
53
|
+
export function resolvePane(panes: PaneInfo[], target: string): PaneInfo | null {
|
|
54
|
+
// 1. Exact pane ID
|
|
55
|
+
if (target.startsWith("%")) {
|
|
56
|
+
return panes.find((p) => p.id === target) ?? null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 2. @ide_name match
|
|
60
|
+
const byName = panes.find((p) => p.name === target);
|
|
61
|
+
if (byName) return byName;
|
|
62
|
+
|
|
63
|
+
// 3. Exact title match
|
|
64
|
+
const byTitle = panes.find((p) => p.title === target);
|
|
65
|
+
if (byTitle) return byTitle;
|
|
66
|
+
|
|
67
|
+
// 4. Role match
|
|
68
|
+
const lower = target.toLowerCase();
|
|
69
|
+
if (["lead", "teammate", "planner"].includes(lower)) {
|
|
70
|
+
const byRole = panes.find((p) => p.role === lower);
|
|
71
|
+
if (byRole) return byRole;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 5. Case-insensitive partial title match
|
|
75
|
+
const byPattern = panes.find((p) => p.title.toLowerCase().includes(lower));
|
|
76
|
+
if (byPattern) return byPattern;
|
|
77
|
+
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function prepareMessage(message: string, busyStatus: PaneBusyStatus): string {
|
|
82
|
+
if (busyStatus === "agent") {
|
|
83
|
+
// Collapse multiline to single line for Claude Code TUI
|
|
84
|
+
// Prevents paste preview that requires manual Enter
|
|
85
|
+
return message.replace(/\n+/g, " ").trim();
|
|
86
|
+
}
|
|
87
|
+
return message;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function send(targetDir: string | undefined, opts: SendOptions): Promise<void> {
|
|
91
|
+
const dir = resolve(targetDir ?? ".");
|
|
92
|
+
const { name: session } = getSessionName(dir);
|
|
93
|
+
const { json, to: target, message: rawMessage, noEnter } = opts;
|
|
94
|
+
|
|
95
|
+
if (!target) {
|
|
96
|
+
throw new IdeError("Missing target. Usage: tmux-ide send <target> <message>", {
|
|
97
|
+
code: "USAGE",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!rawMessage) {
|
|
102
|
+
throw new IdeError("Missing message. Usage: tmux-ide send <target> <message>", {
|
|
103
|
+
code: "USAGE",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Verify session is running
|
|
108
|
+
const state = getSessionState(session);
|
|
109
|
+
if (!state.running) {
|
|
110
|
+
throw new IdeError(`Session "${session}" is not running`, {
|
|
111
|
+
code: "SESSION_NOT_FOUND",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const panes = listSessionPanes(session);
|
|
116
|
+
const pane = resolvePane(panes, target);
|
|
117
|
+
if (!pane) {
|
|
118
|
+
const available = panes
|
|
119
|
+
.map((p) => {
|
|
120
|
+
const label = p.name ?? p.title;
|
|
121
|
+
return ` ${p.id} ${label}${p.role ? ` (${p.role})` : ""}`;
|
|
122
|
+
})
|
|
123
|
+
.join("\n");
|
|
124
|
+
throw new IdeError(`Pane "${target}" not found.\n\nAvailable panes:\n${available}`, {
|
|
125
|
+
code: "PANE_NOT_FOUND",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const busyStatus = getPaneBusyStatus(session, pane.id);
|
|
130
|
+
const message = prepareMessage(rawMessage, busyStatus);
|
|
131
|
+
|
|
132
|
+
let sentViaFile = false;
|
|
133
|
+
if (noEnter) {
|
|
134
|
+
sendText(session, pane.id, message);
|
|
135
|
+
} else {
|
|
136
|
+
const dispatch = writeDispatchFile(dir, pane.id, message);
|
|
137
|
+
if (dispatch) {
|
|
138
|
+
sendCommand(session, pane.id, dispatch.triggerCmd);
|
|
139
|
+
sentViaFile = true;
|
|
140
|
+
} else {
|
|
141
|
+
sendCommand(session, pane.id, message);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Log send event
|
|
146
|
+
appendEvent(dir, {
|
|
147
|
+
timestamp: new Date().toISOString(),
|
|
148
|
+
type: "send",
|
|
149
|
+
target: pane.name ?? pane.title,
|
|
150
|
+
paneId: pane.id,
|
|
151
|
+
message: message.length > 100 ? message.slice(0, 100) + "..." : message,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const result = {
|
|
155
|
+
ok: true,
|
|
156
|
+
session,
|
|
157
|
+
target: {
|
|
158
|
+
paneId: pane.id,
|
|
159
|
+
name: pane.name,
|
|
160
|
+
title: pane.title,
|
|
161
|
+
role: pane.role,
|
|
162
|
+
},
|
|
163
|
+
message,
|
|
164
|
+
busyStatus,
|
|
165
|
+
sentViaFile,
|
|
166
|
+
...(busyStatus === "agent" ? { warning: "agent_busy" } : {}),
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
if (json) {
|
|
170
|
+
console.log(JSON.stringify(result, null, 2));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const label = pane.name ?? pane.title;
|
|
175
|
+
const preview = message.length > 60 ? message.slice(0, 60) + "..." : message;
|
|
176
|
+
console.log(`Sent to "${label}" (${pane.id}): ${preview}`);
|
|
177
|
+
|
|
178
|
+
if (busyStatus === "agent") {
|
|
179
|
+
console.log("Warning: agent appears busy. Message sent anyway.");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach, expect } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { loadSkills, loadSkill } from "./lib/skill-registry.ts";
|
|
6
|
+
|
|
7
|
+
let tmpDir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
tmpDir = mkdtempSync(join(tmpdir(), "tmux-ide-skill-cli-test-"));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("skill list", () => {
|
|
18
|
+
it("returns loaded skills from project directory", () => {
|
|
19
|
+
const skillsDir = join(tmpDir, ".tmux-ide", "skills");
|
|
20
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
21
|
+
writeFileSync(
|
|
22
|
+
join(skillsDir, "frontend.md"),
|
|
23
|
+
`---\nname: frontend\nspecialties: [react, css]\nrole: teammate\ndescription: Frontend dev\n---\nBuild UIs.\n`,
|
|
24
|
+
);
|
|
25
|
+
writeFileSync(
|
|
26
|
+
join(skillsDir, "backend.md"),
|
|
27
|
+
`---\nname: backend\nspecialties: [api]\nrole: teammate\ndescription: Backend dev\n---\nBuild APIs.\n`,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const skills = loadSkills(tmpDir);
|
|
31
|
+
expect(skills.length).toBe(2);
|
|
32
|
+
const names = skills.map((s) => s.name).sort();
|
|
33
|
+
expect(names).toEqual(["backend", "frontend"]);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("skill show", () => {
|
|
38
|
+
it("returns full skill detail by name", () => {
|
|
39
|
+
const skillsDir = join(tmpDir, ".tmux-ide", "skills");
|
|
40
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
41
|
+
writeFileSync(
|
|
42
|
+
join(skillsDir, "test.md"),
|
|
43
|
+
`---\nname: my-skill\nspecialties: [testing]\nrole: validator\ndescription: Test validator\n---\nRun all tests.\n`,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const skill = loadSkill(tmpDir, "my-skill");
|
|
47
|
+
expect(skill).not.toBeNull();
|
|
48
|
+
expect(skill!.name).toBe("my-skill");
|
|
49
|
+
expect(skill!.role).toBe("validator");
|
|
50
|
+
expect(skill!.body).toBe("Run all tests.");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("skill create", () => {
|
|
55
|
+
it("scaffolds a new skill file from template", async () => {
|
|
56
|
+
const { skillCommand } = await import("./skill.ts");
|
|
57
|
+
await skillCommand(tmpDir, { sub: "create", args: ["my-agent"], json: true });
|
|
58
|
+
|
|
59
|
+
const filePath = join(tmpDir, ".tmux-ide", "skills", "my-agent.md");
|
|
60
|
+
expect(existsSync(filePath)).toBe(true);
|
|
61
|
+
|
|
62
|
+
const content = readFileSync(filePath, "utf-8");
|
|
63
|
+
expect(content.includes("name: my-agent")).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("skill validate", () => {
|
|
68
|
+
it("detects unresolved skill references", async () => {
|
|
69
|
+
// Create ide.yml with a skill reference
|
|
70
|
+
writeFileSync(
|
|
71
|
+
join(tmpDir, "ide.yml"),
|
|
72
|
+
`name: test\nrows:\n - panes:\n - title: Agent\n skill: nonexistent-skill\n`,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const { skillCommand } = await import("./skill.ts");
|
|
76
|
+
|
|
77
|
+
// Capture console output
|
|
78
|
+
const logs: string[] = [];
|
|
79
|
+
const origLog = console.log;
|
|
80
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
|
81
|
+
|
|
82
|
+
await skillCommand(tmpDir, { sub: "validate", args: [], json: false });
|
|
83
|
+
|
|
84
|
+
console.log = origLog;
|
|
85
|
+
|
|
86
|
+
expect(logs.some((l) => l.includes("unresolved"))).toBe(true);
|
|
87
|
+
expect(logs.some((l) => l.includes("nonexistent-skill"))).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("reports all references resolved when skills exist", async () => {
|
|
91
|
+
const skillsDir = join(tmpDir, ".tmux-ide", "skills");
|
|
92
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
93
|
+
writeFileSync(
|
|
94
|
+
join(skillsDir, "worker.md"),
|
|
95
|
+
`---\nname: worker\nspecialties: []\nrole: teammate\ndescription: Worker\n---\nWork.\n`,
|
|
96
|
+
);
|
|
97
|
+
writeFileSync(
|
|
98
|
+
join(tmpDir, "ide.yml"),
|
|
99
|
+
`name: test\nrows:\n - panes:\n - title: Agent\n skill: worker\n`,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const { skillCommand } = await import("./skill.ts");
|
|
103
|
+
|
|
104
|
+
const logs: string[] = [];
|
|
105
|
+
const origLog = console.log;
|
|
106
|
+
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
|
107
|
+
|
|
108
|
+
await skillCommand(tmpDir, { sub: "validate", args: [], json: false });
|
|
109
|
+
|
|
110
|
+
console.log = origLog;
|
|
111
|
+
|
|
112
|
+
expect(logs.some((l) => l.includes("All pane skill references resolve"))).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
});
|
package/src/skill.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { resolve, join, dirname } from "node:path";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { outputError } from "./lib/output.ts";
|
|
5
|
+
import { loadSkills, loadSkill } from "./lib/skill-registry.ts";
|
|
6
|
+
import { readConfig } from "./lib/yaml-io.ts";
|
|
7
|
+
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
|
|
10
|
+
export async function skillCommand(
|
|
11
|
+
targetDir: string | undefined,
|
|
12
|
+
{
|
|
13
|
+
json = false,
|
|
14
|
+
sub,
|
|
15
|
+
args = [],
|
|
16
|
+
}: {
|
|
17
|
+
json?: boolean;
|
|
18
|
+
sub?: string;
|
|
19
|
+
args: string[];
|
|
20
|
+
},
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
const dir = resolve(targetDir ?? ".");
|
|
23
|
+
|
|
24
|
+
switch (sub) {
|
|
25
|
+
case "list": {
|
|
26
|
+
const skills = loadSkills(dir);
|
|
27
|
+
if (json) {
|
|
28
|
+
console.log(
|
|
29
|
+
JSON.stringify(
|
|
30
|
+
skills.map((s) => ({
|
|
31
|
+
name: s.name,
|
|
32
|
+
specialties: s.specialties,
|
|
33
|
+
role: s.role,
|
|
34
|
+
description: s.description,
|
|
35
|
+
})),
|
|
36
|
+
null,
|
|
37
|
+
2,
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
} else if (skills.length === 0) {
|
|
41
|
+
console.log("No skills found. Run: tmux-ide skill create <name>");
|
|
42
|
+
} else {
|
|
43
|
+
for (const s of skills) {
|
|
44
|
+
const specs = s.specialties.length > 0 ? ` [${s.specialties.join(", ")}]` : "";
|
|
45
|
+
console.log(` ${s.name}${specs} (${s.role}) — ${s.description}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
case "show": {
|
|
51
|
+
const name = args[0];
|
|
52
|
+
if (!name) outputError("Usage: tmux-ide skill show <name>", "USAGE");
|
|
53
|
+
const skill = loadSkill(dir, name);
|
|
54
|
+
if (!skill) outputError(`Skill "${name}" not found`, "NOT_FOUND");
|
|
55
|
+
if (json) {
|
|
56
|
+
console.log(JSON.stringify(skill, null, 2));
|
|
57
|
+
} else {
|
|
58
|
+
console.log(`Skill: ${skill.name}`);
|
|
59
|
+
console.log(` Role: ${skill.role}`);
|
|
60
|
+
console.log(` Specialties: ${skill.specialties.join(", ") || "none"}`);
|
|
61
|
+
console.log(` Description: ${skill.description}`);
|
|
62
|
+
if (skill.body) {
|
|
63
|
+
console.log(`\n${skill.body}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
case "create": {
|
|
69
|
+
const name = args[0];
|
|
70
|
+
if (!name) outputError("Usage: tmux-ide skill create <name>", "USAGE");
|
|
71
|
+
const skillsDir = join(dir, ".tmux-ide", "skills");
|
|
72
|
+
const filePath = join(skillsDir, `${name}.md`);
|
|
73
|
+
if (existsSync(filePath)) {
|
|
74
|
+
outputError(`Skill "${name}" already exists at ${filePath}`, "EXISTS");
|
|
75
|
+
}
|
|
76
|
+
// Copy from general-worker template, replace name
|
|
77
|
+
const templatePath = resolve(__dirname, "..", "templates", "skills", "general-worker.md");
|
|
78
|
+
let content: string;
|
|
79
|
+
if (existsSync(templatePath)) {
|
|
80
|
+
content = readFileSync(templatePath, "utf-8").replace(/^name: .+/m, `name: ${name}`);
|
|
81
|
+
} else {
|
|
82
|
+
content = `---\nname: ${name}\nspecialties: []\nrole: teammate\ndescription: ${name} agent\n---\nYou are a ${name} agent.\n`;
|
|
83
|
+
}
|
|
84
|
+
if (!existsSync(skillsDir)) mkdirSync(skillsDir, { recursive: true });
|
|
85
|
+
writeFileSync(filePath, content);
|
|
86
|
+
if (json) {
|
|
87
|
+
console.log(JSON.stringify({ created: true, path: filePath }));
|
|
88
|
+
} else {
|
|
89
|
+
console.log(`Created skill "${name}" at ${filePath}`);
|
|
90
|
+
}
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case "validate": {
|
|
94
|
+
let config;
|
|
95
|
+
try {
|
|
96
|
+
({ config } = readConfig(dir));
|
|
97
|
+
} catch {
|
|
98
|
+
outputError("Cannot read ide.yml", "READ_ERROR");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const skills = loadSkills(dir);
|
|
102
|
+
const skillNames = new Set(skills.map((s) => s.name));
|
|
103
|
+
const issues: { pane: string; skill: string }[] = [];
|
|
104
|
+
for (const row of config.rows) {
|
|
105
|
+
for (const pane of row.panes) {
|
|
106
|
+
if (pane.skill && !skillNames.has(pane.skill)) {
|
|
107
|
+
issues.push({ pane: pane.title ?? "untitled", skill: pane.skill });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (json) {
|
|
112
|
+
console.log(JSON.stringify({ valid: issues.length === 0, unresolved: issues }, null, 2));
|
|
113
|
+
} else if (issues.length === 0) {
|
|
114
|
+
console.log("All pane skill references resolve.");
|
|
115
|
+
} else {
|
|
116
|
+
console.log(`${issues.length} unresolved skill reference(s):`);
|
|
117
|
+
for (const i of issues) {
|
|
118
|
+
console.log(` pane "${i.pane}" → skill "${i.skill}" (not found)`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
case "help":
|
|
124
|
+
case undefined:
|
|
125
|
+
console.log(`Usage: tmux-ide skill <list|show|create|validate>
|
|
126
|
+
|
|
127
|
+
list List all skills (project + personal)
|
|
128
|
+
show <name> Show full skill detail
|
|
129
|
+
create <name> Scaffold a new skill file
|
|
130
|
+
validate Check pane skill references resolve`);
|
|
131
|
+
break;
|
|
132
|
+
default:
|
|
133
|
+
outputError(
|
|
134
|
+
"Usage: tmux-ide skill <list|show|create|validate>\nRun: tmux-ide skill help",
|
|
135
|
+
"USAGE",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { getSessionName } from "./lib/yaml-io.ts";
|
|
4
|
+
import {
|
|
5
|
+
getSessionState,
|
|
6
|
+
getSessionVariable,
|
|
7
|
+
getDaemonPort,
|
|
8
|
+
isDaemonHealthy,
|
|
9
|
+
isProcessAlive,
|
|
10
|
+
listPanes,
|
|
11
|
+
} from "./lib/tmux.ts";
|
|
12
|
+
|
|
13
|
+
export async function status(
|
|
14
|
+
targetDir: string | undefined,
|
|
15
|
+
{ json }: { json?: boolean } = {},
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
const dir = resolve(targetDir ?? ".");
|
|
18
|
+
const { name: session } = getSessionName(dir);
|
|
19
|
+
const configExists = existsSync(resolve(dir, "ide.yml"));
|
|
20
|
+
|
|
21
|
+
const state = getSessionState(session);
|
|
22
|
+
const running = state.running;
|
|
23
|
+
let panes: ReturnType<typeof listPanes> = [];
|
|
24
|
+
|
|
25
|
+
if (running) panes = listPanes(session);
|
|
26
|
+
|
|
27
|
+
const monitorPid = running ? getSessionVariable(session, "@monitor_pid") : null;
|
|
28
|
+
const daemonPort = running ? getDaemonPort(session) : null;
|
|
29
|
+
const healthy = daemonPort ? await isDaemonHealthy(session) : false;
|
|
30
|
+
|
|
31
|
+
const data = {
|
|
32
|
+
session,
|
|
33
|
+
running,
|
|
34
|
+
configExists,
|
|
35
|
+
panes,
|
|
36
|
+
daemon: {
|
|
37
|
+
pid: monitorPid ? parseInt(monitorPid, 10) : null,
|
|
38
|
+
alive: monitorPid ? isProcessAlive(parseInt(monitorPid, 10)) : false,
|
|
39
|
+
port: daemonPort,
|
|
40
|
+
healthy,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
if (json) {
|
|
45
|
+
console.log(JSON.stringify(data, null, 2));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.log(`Session: ${session}`);
|
|
50
|
+
console.log(`Running: ${running ? "yes" : "no"}`);
|
|
51
|
+
console.log(`Config: ${configExists ? "ide.yml found" : "no ide.yml"}`);
|
|
52
|
+
|
|
53
|
+
if (running) {
|
|
54
|
+
console.log(
|
|
55
|
+
`Daemon: ${data.daemon.alive ? "running" : "not running"}${data.daemon.port ? ` (port ${data.daemon.port})` : ""}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (panes.length > 0) {
|
|
60
|
+
console.log(`\nPanes:`);
|
|
61
|
+
for (const p of panes) {
|
|
62
|
+
const active = p.active ? " (active)" : "";
|
|
63
|
+
console.log(` ${p.index}: ${p.title} [${p.width}x${p.height}]${active}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/stop.test.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach, mock, expect } from "bun:test";
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { _setExecutor } from "./lib/tmux.ts";
|
|
6
|
+
import { stop, stopDashboard } from "./stop.ts";
|
|
7
|
+
|
|
8
|
+
let mockExec;
|
|
9
|
+
let restoreExec;
|
|
10
|
+
let tmpDir;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
mockExec = mock();
|
|
14
|
+
restoreExec = _setExecutor(mockExec);
|
|
15
|
+
tmpDir = mkdtempSync(join(tmpdir(), "tmux-ide-stop-test-"));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
restoreExec();
|
|
20
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function writeIdeYml(name) {
|
|
24
|
+
writeFileSync(join(tmpDir, "ide.yml"), `name: ${name}\n`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeExecError(stderr) {
|
|
28
|
+
const err = new Error("Command failed");
|
|
29
|
+
err.stderr = stderr;
|
|
30
|
+
return err;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("stop", () => {
|
|
34
|
+
it("calls stopSessionMonitor before killSession", async () => {
|
|
35
|
+
writeIdeYml("my-app");
|
|
36
|
+
const callLog = [];
|
|
37
|
+
mockExec.mockImplementation((_cmd, args) => {
|
|
38
|
+
callLog.push(args[0]);
|
|
39
|
+
if (args[0] === "show-option") return "\n";
|
|
40
|
+
return "";
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const logged = [];
|
|
44
|
+
const origLog = console.log;
|
|
45
|
+
console.log = (...a) => logged.push(a.join(" "));
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
await stop(tmpDir);
|
|
49
|
+
// show-option (stopSessionMonitor) should come before kill-session
|
|
50
|
+
const showIdx = callLog.indexOf("show-option");
|
|
51
|
+
const killIdx = callLog.indexOf("kill-session");
|
|
52
|
+
expect(showIdx < killIdx).toBeTruthy();
|
|
53
|
+
} finally {
|
|
54
|
+
console.log = origLog;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("outputs JSON when --json is passed", async () => {
|
|
59
|
+
writeIdeYml("test-proj");
|
|
60
|
+
mockExec.mockImplementation((_cmd, args) => {
|
|
61
|
+
if (args[0] === "show-option") return "\n";
|
|
62
|
+
return "";
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const logged = [];
|
|
66
|
+
const origLog = console.log;
|
|
67
|
+
console.log = (...a) => logged.push(a.join(" "));
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
await stop(tmpDir, { json: true });
|
|
71
|
+
const output = JSON.parse(logged[0]);
|
|
72
|
+
expect(output).toEqual({ stopped: "test-proj" });
|
|
73
|
+
} finally {
|
|
74
|
+
console.log = origLog;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("outputs human-readable message by default", async () => {
|
|
79
|
+
writeIdeYml("my-app");
|
|
80
|
+
mockExec.mockImplementation((_cmd, args) => {
|
|
81
|
+
if (args[0] === "show-option") return "\n";
|
|
82
|
+
return "";
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const logged = [];
|
|
86
|
+
const origLog = console.log;
|
|
87
|
+
console.log = (...a) => logged.push(a.join(" "));
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
await stop(tmpDir);
|
|
91
|
+
expect(logged[0].includes('Stopped session "my-app"')).toBeTruthy();
|
|
92
|
+
} finally {
|
|
93
|
+
console.log = origLog;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("throws IdeError when session not found", async () => {
|
|
98
|
+
writeIdeYml("missing");
|
|
99
|
+
mockExec.mockImplementation((_cmd, args) => {
|
|
100
|
+
if (args[0] === "show-option") {
|
|
101
|
+
throw makeExecError("can't find session: missing");
|
|
102
|
+
}
|
|
103
|
+
if (args[0] === "kill-session") {
|
|
104
|
+
throw makeExecError("can't find session: missing");
|
|
105
|
+
}
|
|
106
|
+
return "";
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await expect(stop(tmpDir)).rejects.toThrow("No active session");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("falls back to dir basename when ide.yml has no name", async () => {
|
|
113
|
+
// Write minimal YAML without a name field
|
|
114
|
+
writeFileSync(join(tmpDir, "ide.yml"), "rows:\n - panes:\n - title: Shell\n");
|
|
115
|
+
mockExec.mockImplementation((_cmd, args) => {
|
|
116
|
+
if (args[0] === "show-option") return "\n";
|
|
117
|
+
return "";
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const logged = [];
|
|
121
|
+
const origLog = console.log;
|
|
122
|
+
console.log = (...a) => logged.push(a.join(" "));
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
await stop(tmpDir);
|
|
126
|
+
// Session name should be the basename of the temp dir
|
|
127
|
+
expect(logged[0].includes("Stopped session")).toBeTruthy();
|
|
128
|
+
} finally {
|
|
129
|
+
console.log = origLog;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("falls back to dir basename when no ide.yml exists", async () => {
|
|
134
|
+
// No ide.yml written
|
|
135
|
+
mockExec.mockImplementation((_cmd, args) => {
|
|
136
|
+
if (args[0] === "show-option") return "\n";
|
|
137
|
+
return "";
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const logged = [];
|
|
141
|
+
const origLog = console.log;
|
|
142
|
+
console.log = (...a) => logged.push(a.join(" "));
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
await stop(tmpDir);
|
|
146
|
+
expect(logged[0].includes("Stopped session")).toBeTruthy();
|
|
147
|
+
} finally {
|
|
148
|
+
console.log = origLog;
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("stopDashboard", () => {
|
|
154
|
+
it("kills the stored dashboard pid", () => {
|
|
155
|
+
writeIdeYml("my-app");
|
|
156
|
+
mockExec.mockImplementation((_cmd, args) => {
|
|
157
|
+
if (args[0] === "show-option" && args[3] === "@dashboard_pid") return "4321\n";
|
|
158
|
+
return "\n";
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const origKill = process.kill;
|
|
162
|
+
const kills: Array<{ pid: number; signal?: string | number }> = [];
|
|
163
|
+
process.kill = ((pid: number, signal?: string | number) => {
|
|
164
|
+
kills.push({ pid, signal });
|
|
165
|
+
return true;
|
|
166
|
+
}) as typeof process.kill;
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
stopDashboard("my-app");
|
|
170
|
+
} finally {
|
|
171
|
+
process.kill = origKill;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
expect(kills[0]).toEqual({ pid: -4321, signal: "SIGTERM" });
|
|
175
|
+
});
|
|
176
|
+
});
|