@lawrence369/loop-cli 0.1.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/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/dist/agent/activity.d.ts +64 -0
- package/dist/agent/activity.js +265 -0
- package/dist/agent/launcher.d.ts +42 -0
- package/dist/agent/launcher.js +243 -0
- package/dist/agent/pty-session.d.ts +113 -0
- package/dist/agent/pty-session.js +490 -0
- package/dist/agent/ready-detector.d.ts +46 -0
- package/dist/agent/ready-detector.js +86 -0
- package/dist/agent/wrapper.d.ts +18 -0
- package/dist/agent/wrapper.js +110 -0
- package/dist/bin/lclaude.d.ts +3 -0
- package/dist/bin/lclaude.js +7 -0
- package/dist/bin/lcodex.d.ts +3 -0
- package/dist/bin/lcodex.js +7 -0
- package/dist/bin/lgemini.d.ts +3 -0
- package/dist/bin/lgemini.js +7 -0
- package/dist/bus/daemon.d.ts +56 -0
- package/dist/bus/daemon.js +135 -0
- package/dist/bus/event-bus.d.ts +105 -0
- package/dist/bus/event-bus.js +157 -0
- package/dist/bus/message.d.ts +48 -0
- package/dist/bus/message.js +129 -0
- package/dist/bus/queue.d.ts +50 -0
- package/dist/bus/queue.js +100 -0
- package/dist/bus/store.d.ts +88 -0
- package/dist/bus/store.js +212 -0
- package/dist/bus/subscriber.d.ts +76 -0
- package/dist/bus/subscriber.js +187 -0
- package/dist/config/index.d.ts +8 -0
- package/dist/config/index.js +72 -0
- package/dist/config/schema.d.ts +18 -0
- package/dist/config/schema.js +58 -0
- package/dist/core/conversation.d.ts +34 -0
- package/dist/core/conversation.js +289 -0
- package/dist/core/engine.d.ts +40 -0
- package/dist/core/engine.js +288 -0
- package/dist/core/loop.d.ts +33 -0
- package/dist/core/loop.js +209 -0
- package/dist/core/protocol.d.ts +60 -0
- package/dist/core/protocol.js +162 -0
- package/dist/core/scoring.d.ts +34 -0
- package/dist/core/scoring.js +69 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +408 -0
- package/dist/orchestrator/daemon.d.ts +74 -0
- package/dist/orchestrator/daemon.js +294 -0
- package/dist/orchestrator/group.d.ts +73 -0
- package/dist/orchestrator/group.js +166 -0
- package/dist/orchestrator/ipc-server.d.ts +60 -0
- package/dist/orchestrator/ipc-server.js +166 -0
- package/dist/orchestrator/scheduler.d.ts +32 -0
- package/dist/orchestrator/scheduler.js +95 -0
- package/dist/plan/context.d.ts +8 -0
- package/dist/plan/context.js +42 -0
- package/dist/plan/decisions.d.ts +18 -0
- package/dist/plan/decisions.js +143 -0
- package/dist/plan/shared-plan.d.ts +33 -0
- package/dist/plan/shared-plan.js +211 -0
- package/dist/skills/executor.d.ts +7 -0
- package/dist/skills/executor.js +11 -0
- package/dist/skills/loader.d.ts +16 -0
- package/dist/skills/loader.js +80 -0
- package/dist/skills/registry.d.ts +13 -0
- package/dist/skills/registry.js +54 -0
- package/dist/terminal/adapter.d.ts +61 -0
- package/dist/terminal/adapter.js +42 -0
- package/dist/terminal/detect.d.ts +30 -0
- package/dist/terminal/detect.js +77 -0
- package/dist/terminal/iterm2-adapter.d.ts +19 -0
- package/dist/terminal/iterm2-adapter.js +120 -0
- package/dist/terminal/pty-adapter.d.ts +18 -0
- package/dist/terminal/pty-adapter.js +84 -0
- package/dist/terminal/terminal-adapter.d.ts +17 -0
- package/dist/terminal/terminal-adapter.js +94 -0
- package/dist/terminal/tmux-adapter.d.ts +18 -0
- package/dist/terminal/tmux-adapter.js +127 -0
- package/dist/ui/banner.d.ts +3 -0
- package/dist/ui/banner.js +145 -0
- package/dist/ui/colors.d.ts +41 -0
- package/dist/ui/colors.js +65 -0
- package/dist/ui/dashboard.d.ts +32 -0
- package/dist/ui/dashboard.js +138 -0
- package/dist/ui/input.d.ts +10 -0
- package/dist/ui/input.js +96 -0
- package/dist/ui/interactive.d.ts +13 -0
- package/dist/ui/interactive.js +230 -0
- package/dist/ui/renderer.d.ts +33 -0
- package/dist/ui/renderer.js +106 -0
- package/dist/utils/ansi.d.ts +11 -0
- package/dist/utils/ansi.js +16 -0
- package/dist/utils/fs.d.ts +34 -0
- package/dist/utils/fs.js +115 -0
- package/dist/utils/lock.d.ts +12 -0
- package/dist/utils/lock.js +116 -0
- package/dist/utils/process.d.ts +31 -0
- package/dist/utils/process.js +111 -0
- package/dist/utils/pty-filter.d.ts +31 -0
- package/dist/utils/pty-filter.js +187 -0
- package/package.json +71 -0
- package/skills/loop/SKILL.md +19 -0
- package/skills/plan/SKILL.md +9 -0
- package/skills/review/SKILL.md +14 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
const SHARED_PLAN_FILENAME = ".loop-plan.md";
|
|
4
|
+
// ── Helpers ─────────────────────────────────────────
|
|
5
|
+
function getPlanPath(cwd) {
|
|
6
|
+
return join(cwd, SHARED_PLAN_FILENAME);
|
|
7
|
+
}
|
|
8
|
+
function readPlan(cwd) {
|
|
9
|
+
const path = getPlanPath(cwd);
|
|
10
|
+
if (!existsSync(path))
|
|
11
|
+
return null;
|
|
12
|
+
try {
|
|
13
|
+
const content = readFileSync(path, "utf-8");
|
|
14
|
+
return parsePlan(content);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function writePlanSafe(cwd, content) {
|
|
21
|
+
try {
|
|
22
|
+
writeFileSync(getPlanPath(cwd), content, "utf-8");
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Fail-silent: never crash on write errors
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// ── Public API ──────────────────────────────────────
|
|
29
|
+
/** Initialize a new shared plan file. */
|
|
30
|
+
export async function initSharedPlan(cwd, task) {
|
|
31
|
+
const content = renderPlan({
|
|
32
|
+
task,
|
|
33
|
+
iterations: [],
|
|
34
|
+
fileChangeLog: [],
|
|
35
|
+
});
|
|
36
|
+
writePlanSafe(cwd, content);
|
|
37
|
+
}
|
|
38
|
+
/** Update the shared plan with a new iteration record. */
|
|
39
|
+
export async function updateSharedPlan(cwd, record, filesChanged) {
|
|
40
|
+
const plan = readPlan(cwd) ?? {
|
|
41
|
+
task: "",
|
|
42
|
+
iterations: [],
|
|
43
|
+
fileChangeLog: [],
|
|
44
|
+
};
|
|
45
|
+
plan.iterations.push(record);
|
|
46
|
+
for (const file of filesChanged) {
|
|
47
|
+
plan.fileChangeLog.push({
|
|
48
|
+
iteration: record.iteration,
|
|
49
|
+
file,
|
|
50
|
+
action: "modified",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
writePlanSafe(cwd, renderPlan(plan));
|
|
54
|
+
}
|
|
55
|
+
/** Generate a context snippet from the shared plan for the executor prompt. */
|
|
56
|
+
export async function getExecutorContext(cwd) {
|
|
57
|
+
const plan = readPlan(cwd);
|
|
58
|
+
if (!plan || plan.iterations.length === 0)
|
|
59
|
+
return "";
|
|
60
|
+
const lastIter = plan.iterations[plan.iterations.length - 1];
|
|
61
|
+
const lines = [
|
|
62
|
+
"## Previous Iteration Context (from shared plan)",
|
|
63
|
+
"",
|
|
64
|
+
`Last iteration: ${lastIter.iteration}`,
|
|
65
|
+
`Reviewer score: ${lastIter.score}/10`,
|
|
66
|
+
`Approved: ${lastIter.approved ? "Yes" : "No"}`,
|
|
67
|
+
"",
|
|
68
|
+
"Recent feedback:",
|
|
69
|
+
lastIter.reviewerFeedback,
|
|
70
|
+
];
|
|
71
|
+
if (plan.fileChangeLog.length > 0) {
|
|
72
|
+
lines.push("");
|
|
73
|
+
lines.push("Files changed so far:");
|
|
74
|
+
for (const change of plan.fileChangeLog) {
|
|
75
|
+
lines.push(`- ${change.file} (${change.action}, iteration ${change.iteration})`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return lines.join("\n");
|
|
79
|
+
}
|
|
80
|
+
/** Generate a context snippet for the reviewer prompt. */
|
|
81
|
+
export async function getReviewerContext(cwd) {
|
|
82
|
+
const plan = readPlan(cwd);
|
|
83
|
+
if (!plan || plan.iterations.length === 0)
|
|
84
|
+
return "";
|
|
85
|
+
const lines = [
|
|
86
|
+
"## Iteration History (from shared plan)",
|
|
87
|
+
"",
|
|
88
|
+
];
|
|
89
|
+
for (const iter of plan.iterations) {
|
|
90
|
+
lines.push(`### Iteration ${iter.iteration}: Score ${iter.score}/10 (${iter.approved ? "Approved" : "Not approved"})`);
|
|
91
|
+
lines.push(`Key feedback: ${iter.reviewerFeedback.split("\n")[0]}`);
|
|
92
|
+
lines.push("");
|
|
93
|
+
}
|
|
94
|
+
return lines.join("\n");
|
|
95
|
+
}
|
|
96
|
+
/** Clear the shared plan file. */
|
|
97
|
+
export async function clearPlan(cwd) {
|
|
98
|
+
try {
|
|
99
|
+
const path = getPlanPath(cwd);
|
|
100
|
+
if (existsSync(path)) {
|
|
101
|
+
const { unlinkSync } = await import("node:fs");
|
|
102
|
+
unlinkSync(path);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Fail-silent
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Show the raw plan content. */
|
|
110
|
+
export async function showPlan(cwd) {
|
|
111
|
+
const path = getPlanPath(cwd);
|
|
112
|
+
if (!existsSync(path))
|
|
113
|
+
return "No plan file found.";
|
|
114
|
+
try {
|
|
115
|
+
return readFileSync(path, "utf-8");
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return "Error reading plan file.";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// ── Rendering ───────────────────────────────────────
|
|
122
|
+
function renderPlan(plan) {
|
|
123
|
+
const sections = [];
|
|
124
|
+
sections.push("# Loop Shared Plan");
|
|
125
|
+
sections.push("");
|
|
126
|
+
sections.push("## Task");
|
|
127
|
+
sections.push(plan.task);
|
|
128
|
+
sections.push("");
|
|
129
|
+
sections.push("## Iteration History");
|
|
130
|
+
sections.push("");
|
|
131
|
+
if (plan.iterations.length === 0) {
|
|
132
|
+
sections.push("_No iterations yet._");
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
for (const iter of plan.iterations) {
|
|
136
|
+
sections.push(`### Iteration ${iter.iteration}`);
|
|
137
|
+
sections.push(`- **Timestamp:** ${iter.timestamp}`);
|
|
138
|
+
sections.push(`- **Executor:** ${iter.executor}`);
|
|
139
|
+
sections.push(`- **Reviewer:** ${iter.reviewer}`);
|
|
140
|
+
sections.push(`- **Score:** ${iter.score}/10`);
|
|
141
|
+
sections.push(`- **Approved:** ${iter.approved ? "Yes" : "No"}`);
|
|
142
|
+
sections.push("");
|
|
143
|
+
sections.push("**Executor Summary:**");
|
|
144
|
+
sections.push(iter.executorSummary);
|
|
145
|
+
sections.push("");
|
|
146
|
+
sections.push("**Reviewer Feedback:**");
|
|
147
|
+
sections.push(iter.reviewerFeedback);
|
|
148
|
+
sections.push("");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
sections.push("## File Change Log");
|
|
152
|
+
sections.push("");
|
|
153
|
+
if (plan.fileChangeLog.length === 0) {
|
|
154
|
+
sections.push("_No file changes recorded._");
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
for (const change of plan.fileChangeLog) {
|
|
158
|
+
sections.push(`- [Iteration ${change.iteration}] ${change.action}: ${change.file}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
sections.push("");
|
|
162
|
+
return sections.join("\n");
|
|
163
|
+
}
|
|
164
|
+
// ── Parsing ─────────────────────────────────────────
|
|
165
|
+
function parsePlan(content) {
|
|
166
|
+
const plan = {
|
|
167
|
+
task: "",
|
|
168
|
+
iterations: [],
|
|
169
|
+
fileChangeLog: [],
|
|
170
|
+
};
|
|
171
|
+
// Extract task
|
|
172
|
+
const taskMatch = content.match(/## Task\n([\s\S]*?)(?=\n## )/);
|
|
173
|
+
if (taskMatch)
|
|
174
|
+
plan.task = taskMatch[1].trim();
|
|
175
|
+
// Extract iteration records
|
|
176
|
+
const iterPattern = /### Iteration (\d+)\n([\s\S]*?)(?=\n### Iteration |\n## |$)/g;
|
|
177
|
+
let iterMatch;
|
|
178
|
+
while ((iterMatch = iterPattern.exec(content)) !== null) {
|
|
179
|
+
const num = parseInt(iterMatch[1], 10);
|
|
180
|
+
const body = iterMatch[2];
|
|
181
|
+
const scoreMatch = body.match(/\*\*Score:\*\*\s*(\d+)/);
|
|
182
|
+
const approvedMatch = body.match(/\*\*Approved:\*\*\s*(Yes|No)/i);
|
|
183
|
+
const executorMatch = body.match(/\*\*Executor:\*\*\s*(\w+)/);
|
|
184
|
+
const reviewerMatch = body.match(/\*\*Reviewer:\*\*\s*(\w+)/);
|
|
185
|
+
const timestampMatch = body.match(/\*\*Timestamp:\*\*\s*(.+)/);
|
|
186
|
+
const summaryMatch = body.match(/\*\*Executor Summary:\*\*\n([\s\S]*?)(?=\n\*\*Reviewer Feedback:\*\*|$)/);
|
|
187
|
+
const feedbackMatch = body.match(/\*\*Reviewer Feedback:\*\*\n([\s\S]*?)$/);
|
|
188
|
+
plan.iterations.push({
|
|
189
|
+
iteration: num,
|
|
190
|
+
timestamp: timestampMatch?.[1]?.trim() ?? "",
|
|
191
|
+
executor: executorMatch?.[1] ?? "",
|
|
192
|
+
reviewer: reviewerMatch?.[1] ?? "",
|
|
193
|
+
executorSummary: summaryMatch?.[1]?.trim() ?? "",
|
|
194
|
+
score: scoreMatch ? parseInt(scoreMatch[1], 10) : 0,
|
|
195
|
+
approved: approvedMatch?.[1]?.toLowerCase() === "yes",
|
|
196
|
+
reviewerFeedback: feedbackMatch?.[1]?.trim() ?? "",
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
// Extract file change log
|
|
200
|
+
const changePattern = /- \[Iteration (\d+)\] (created|modified|deleted): (.+)/g;
|
|
201
|
+
let changeMatch;
|
|
202
|
+
while ((changeMatch = changePattern.exec(content)) !== null) {
|
|
203
|
+
plan.fileChangeLog.push({
|
|
204
|
+
iteration: parseInt(changeMatch[1], 10),
|
|
205
|
+
file: changeMatch[3].trim(),
|
|
206
|
+
action: changeMatch[2],
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return plan;
|
|
210
|
+
}
|
|
211
|
+
//# sourceMappingURL=shared-plan.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type Skill } from "./loader.js";
|
|
2
|
+
/**
|
|
3
|
+
* Inject skill content into a prompt.
|
|
4
|
+
* Each skill is wrapped with delimiters for clarity.
|
|
5
|
+
*/
|
|
6
|
+
export declare function injectSkills(prompt: string, skills: Skill[]): string;
|
|
7
|
+
//# sourceMappingURL=executor.d.ts.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inject skill content into a prompt.
|
|
3
|
+
* Each skill is wrapped with delimiters for clarity.
|
|
4
|
+
*/
|
|
5
|
+
export function injectSkills(prompt, skills) {
|
|
6
|
+
if (skills.length === 0)
|
|
7
|
+
return prompt;
|
|
8
|
+
const skillBlocks = skills.map((skill) => `--- SKILL: ${skill.name} ---\n${skill.content}\n--- END SKILL ---`);
|
|
9
|
+
return skillBlocks.join("\n\n") + "\n\n" + prompt;
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=executor.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface Skill {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
content: string;
|
|
5
|
+
path: string;
|
|
6
|
+
scope: "global" | "project" | "builtin";
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Discover skills from multiple locations.
|
|
10
|
+
* Search order (later scopes override earlier on name collision):
|
|
11
|
+
* 1. Built-in: <packageRoot>/skills/<name>/SKILL.md
|
|
12
|
+
* 2. Global: ~/.loop/skills/<name>/SKILL.md
|
|
13
|
+
* 3. Project: <cwd>/SKILLS/<name>/SKILL.md
|
|
14
|
+
*/
|
|
15
|
+
export declare function discoverSkills(cwd: string): Promise<Skill[]>;
|
|
16
|
+
//# sourceMappingURL=loader.d.ts.map
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import matter from "gray-matter";
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const PACKAGE_ROOT = join(__dirname, "..", "..");
|
|
8
|
+
function readSkillFile(skillDir, scope) {
|
|
9
|
+
const skillPath = join(skillDir, "SKILL.md");
|
|
10
|
+
if (!existsSync(skillPath))
|
|
11
|
+
return null;
|
|
12
|
+
try {
|
|
13
|
+
const raw = readFileSync(skillPath, "utf-8");
|
|
14
|
+
const parsed = matter(raw);
|
|
15
|
+
const data = parsed.data;
|
|
16
|
+
const name = typeof data.name === "string" && data.name
|
|
17
|
+
? data.name
|
|
18
|
+
: skillDir.split("/").pop() ?? "unknown";
|
|
19
|
+
const description = typeof data.description === "string"
|
|
20
|
+
? data.description.trim()
|
|
21
|
+
: "";
|
|
22
|
+
return {
|
|
23
|
+
name,
|
|
24
|
+
description,
|
|
25
|
+
content: parsed.content.trim(),
|
|
26
|
+
path: skillPath,
|
|
27
|
+
scope,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function discoverFromDir(baseDir, scope) {
|
|
35
|
+
if (!existsSync(baseDir))
|
|
36
|
+
return [];
|
|
37
|
+
const skills = [];
|
|
38
|
+
try {
|
|
39
|
+
const entries = readdirSync(baseDir, { withFileTypes: true });
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
if (entry.isDirectory()) {
|
|
42
|
+
const skill = readSkillFile(join(baseDir, entry.name), scope);
|
|
43
|
+
if (skill) {
|
|
44
|
+
skills.push(skill);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Directory not readable — skip
|
|
51
|
+
}
|
|
52
|
+
return skills;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Discover skills from multiple locations.
|
|
56
|
+
* Search order (later scopes override earlier on name collision):
|
|
57
|
+
* 1. Built-in: <packageRoot>/skills/<name>/SKILL.md
|
|
58
|
+
* 2. Global: ~/.loop/skills/<name>/SKILL.md
|
|
59
|
+
* 3. Project: <cwd>/SKILLS/<name>/SKILL.md
|
|
60
|
+
*/
|
|
61
|
+
export async function discoverSkills(cwd) {
|
|
62
|
+
const seen = new Map();
|
|
63
|
+
// 1. Built-in skills
|
|
64
|
+
const builtinDir = join(PACKAGE_ROOT, "skills");
|
|
65
|
+
for (const skill of discoverFromDir(builtinDir, "builtin")) {
|
|
66
|
+
seen.set(skill.name, skill);
|
|
67
|
+
}
|
|
68
|
+
// 2. Global skills (~/.loop/skills/)
|
|
69
|
+
const globalDir = join(homedir(), ".loop", "skills");
|
|
70
|
+
for (const skill of discoverFromDir(globalDir, "global")) {
|
|
71
|
+
seen.set(skill.name, skill);
|
|
72
|
+
}
|
|
73
|
+
// 3. Project skills (<cwd>/SKILLS/)
|
|
74
|
+
const projectDir = join(cwd, "SKILLS");
|
|
75
|
+
for (const skill of discoverFromDir(projectDir, "project")) {
|
|
76
|
+
seen.set(skill.name, skill);
|
|
77
|
+
}
|
|
78
|
+
return Array.from(seen.values());
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=loader.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Skill } from "./loader.js";
|
|
2
|
+
export declare class SkillRegistry {
|
|
3
|
+
private skills;
|
|
4
|
+
/** Load/reload all discoverable skills. */
|
|
5
|
+
load(cwd: string): Promise<void>;
|
|
6
|
+
/** Get a single skill by name. */
|
|
7
|
+
get(name: string): Skill | undefined;
|
|
8
|
+
/** List all loaded skills. */
|
|
9
|
+
list(): Skill[];
|
|
10
|
+
/** Add a new skill (write SKILL.md to disk). */
|
|
11
|
+
add(name: string, content: string, scope: "global" | "project", cwd?: string): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=registry.d.ts.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { discoverSkills } from "./loader.js";
|
|
5
|
+
export class SkillRegistry {
|
|
6
|
+
skills = new Map();
|
|
7
|
+
/** Load/reload all discoverable skills. */
|
|
8
|
+
async load(cwd) {
|
|
9
|
+
this.skills.clear();
|
|
10
|
+
const discovered = await discoverSkills(cwd);
|
|
11
|
+
for (const skill of discovered) {
|
|
12
|
+
this.skills.set(skill.name, skill);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Get a single skill by name. */
|
|
16
|
+
get(name) {
|
|
17
|
+
return this.skills.get(name);
|
|
18
|
+
}
|
|
19
|
+
/** List all loaded skills. */
|
|
20
|
+
list() {
|
|
21
|
+
return Array.from(this.skills.values());
|
|
22
|
+
}
|
|
23
|
+
/** Add a new skill (write SKILL.md to disk). */
|
|
24
|
+
async add(name, content, scope, cwd) {
|
|
25
|
+
// Prevent path traversal
|
|
26
|
+
if (/[/\\]|^\.\.?$/.test(name) || name.includes("..")) {
|
|
27
|
+
throw new Error(`Invalid skill name: ${name}`);
|
|
28
|
+
}
|
|
29
|
+
let baseDir;
|
|
30
|
+
if (scope === "global") {
|
|
31
|
+
baseDir = join(homedir(), ".loop", "skills");
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
if (!cwd) {
|
|
35
|
+
throw new Error("cwd is required when adding a project-scoped skill");
|
|
36
|
+
}
|
|
37
|
+
baseDir = join(cwd, "SKILLS");
|
|
38
|
+
}
|
|
39
|
+
const skillDir = join(baseDir, name);
|
|
40
|
+
mkdirSync(skillDir, { recursive: true });
|
|
41
|
+
const skillPath = join(skillDir, "SKILL.md");
|
|
42
|
+
const fileContent = `---\nname: ${name}\ndescription: ""\n---\n\n${content}\n`;
|
|
43
|
+
writeFileSync(skillPath, fileContent, "utf-8");
|
|
44
|
+
// Register in memory
|
|
45
|
+
this.skills.set(name, {
|
|
46
|
+
name,
|
|
47
|
+
description: "",
|
|
48
|
+
content,
|
|
49
|
+
path: skillPath,
|
|
50
|
+
scope,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=registry.js.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal adapter interface and factory.
|
|
3
|
+
*
|
|
4
|
+
* Each adapter wraps a specific terminal environment (Terminal.app, tmux,
|
|
5
|
+
* iTerm2, internal PTY) behind a common interface so the agent launcher
|
|
6
|
+
* can spawn and manage processes uniformly.
|
|
7
|
+
*
|
|
8
|
+
* Ported from ufoo's adapterRouter.js / adapterContract.js with simplified
|
|
9
|
+
* TypeScript interfaces.
|
|
10
|
+
*/
|
|
11
|
+
import type { LaunchMode } from "./detect.js";
|
|
12
|
+
export interface TerminalCapabilities {
|
|
13
|
+
/** Can bring the terminal window / pane to the foreground. */
|
|
14
|
+
supportsActivate: boolean;
|
|
15
|
+
/** Can inject text into a running process via send-keys or socket. */
|
|
16
|
+
supportsInjection: boolean;
|
|
17
|
+
/** Supports reusing a previous session in the same terminal / pane. */
|
|
18
|
+
supportsSessionReuse: boolean;
|
|
19
|
+
/** Can programmatically resize the PTY. */
|
|
20
|
+
supportsResize: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface AdapterLaunchOptions {
|
|
23
|
+
cwd: string;
|
|
24
|
+
env?: Record<string, string>;
|
|
25
|
+
cols?: number;
|
|
26
|
+
rows?: number;
|
|
27
|
+
}
|
|
28
|
+
export interface LaunchedProcess {
|
|
29
|
+
pid: number;
|
|
30
|
+
write(data: string): void;
|
|
31
|
+
resize(cols: number, rows: number): void;
|
|
32
|
+
kill(): void;
|
|
33
|
+
onData(handler: (data: string) => void): void;
|
|
34
|
+
onExit(handler: (code: number) => void): void;
|
|
35
|
+
}
|
|
36
|
+
export interface TerminalAdapter {
|
|
37
|
+
mode: LaunchMode;
|
|
38
|
+
capabilities: TerminalCapabilities;
|
|
39
|
+
/**
|
|
40
|
+
* Spawn a new process inside this terminal environment.
|
|
41
|
+
*/
|
|
42
|
+
launch(command: string, args: string[], opts: AdapterLaunchOptions): Promise<LaunchedProcess>;
|
|
43
|
+
/**
|
|
44
|
+
* Inject text into a running process (e.g. tmux send-keys).
|
|
45
|
+
* Only available when `capabilities.supportsInjection` is true.
|
|
46
|
+
*/
|
|
47
|
+
inject?(processOrId: number | string, command: string): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Bring the terminal window / pane to the foreground.
|
|
50
|
+
* Only available when `capabilities.supportsActivate` is true.
|
|
51
|
+
*/
|
|
52
|
+
activate?(processOrId: number | string): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Create the appropriate terminal adapter for the given launch mode.
|
|
56
|
+
*
|
|
57
|
+
* Adapters are lazily imported to avoid loading unnecessary platform-specific
|
|
58
|
+
* code (e.g. osascript helpers on Linux).
|
|
59
|
+
*/
|
|
60
|
+
export declare function createAdapter(mode: LaunchMode): Promise<TerminalAdapter>;
|
|
61
|
+
//# sourceMappingURL=adapter.d.ts.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal adapter interface and factory.
|
|
3
|
+
*
|
|
4
|
+
* Each adapter wraps a specific terminal environment (Terminal.app, tmux,
|
|
5
|
+
* iTerm2, internal PTY) behind a common interface so the agent launcher
|
|
6
|
+
* can spawn and manage processes uniformly.
|
|
7
|
+
*
|
|
8
|
+
* Ported from ufoo's adapterRouter.js / adapterContract.js with simplified
|
|
9
|
+
* TypeScript interfaces.
|
|
10
|
+
*/
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Factory
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
/**
|
|
15
|
+
* Create the appropriate terminal adapter for the given launch mode.
|
|
16
|
+
*
|
|
17
|
+
* Adapters are lazily imported to avoid loading unnecessary platform-specific
|
|
18
|
+
* code (e.g. osascript helpers on Linux).
|
|
19
|
+
*/
|
|
20
|
+
export async function createAdapter(mode) {
|
|
21
|
+
switch (mode) {
|
|
22
|
+
case "terminal": {
|
|
23
|
+
const { NativeTerminalAdapter } = await import("./terminal-adapter.js");
|
|
24
|
+
return new NativeTerminalAdapter();
|
|
25
|
+
}
|
|
26
|
+
case "tmux": {
|
|
27
|
+
const { TmuxAdapter } = await import("./tmux-adapter.js");
|
|
28
|
+
return new TmuxAdapter();
|
|
29
|
+
}
|
|
30
|
+
case "iterm2": {
|
|
31
|
+
const { ITerm2Adapter } = await import("./iterm2-adapter.js");
|
|
32
|
+
return new ITerm2Adapter();
|
|
33
|
+
}
|
|
34
|
+
case "pty":
|
|
35
|
+
case "auto":
|
|
36
|
+
default: {
|
|
37
|
+
const { PtyAdapter } = await import("./pty-adapter.js");
|
|
38
|
+
return new PtyAdapter();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=adapter.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal environment detection.
|
|
3
|
+
*
|
|
4
|
+
* Identifies the terminal emulator (iTerm2, tmux, Terminal.app, etc.)
|
|
5
|
+
* and resolves a launch mode for agent spawning.
|
|
6
|
+
*
|
|
7
|
+
* Ported from ufoo's terminal/detect.js and launcher.js `resolveLaunchMode`.
|
|
8
|
+
*/
|
|
9
|
+
export type LaunchMode = "terminal" | "tmux" | "iterm2" | "pty" | "auto";
|
|
10
|
+
/**
|
|
11
|
+
* Detect the appropriate launch mode based on environment variables.
|
|
12
|
+
*
|
|
13
|
+
* Priority:
|
|
14
|
+
* 1. LOOP_LAUNCH_MODE env override (explicit user choice)
|
|
15
|
+
* 2. TMUX_PANE present → "tmux"
|
|
16
|
+
* 3. ITERM_SESSION_ID present → "iterm2"
|
|
17
|
+
* 4. Fallback → "pty" (headless internal PTY)
|
|
18
|
+
*/
|
|
19
|
+
export declare function detectTerminal(): LaunchMode;
|
|
20
|
+
/**
|
|
21
|
+
* Detect the TTY device path for the current process.
|
|
22
|
+
* Returns `undefined` when running without a controlling terminal.
|
|
23
|
+
*/
|
|
24
|
+
export declare function detectTTY(): string | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* Return the current tmux pane identifier (e.g. `%0`, `%1`).
|
|
27
|
+
* Returns `undefined` when not inside a tmux session.
|
|
28
|
+
*/
|
|
29
|
+
export declare function detectTmuxPane(): string | undefined;
|
|
30
|
+
//# sourceMappingURL=detect.d.ts.map
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal environment detection.
|
|
3
|
+
*
|
|
4
|
+
* Identifies the terminal emulator (iTerm2, tmux, Terminal.app, etc.)
|
|
5
|
+
* and resolves a launch mode for agent spawning.
|
|
6
|
+
*
|
|
7
|
+
* Ported from ufoo's terminal/detect.js and launcher.js `resolveLaunchMode`.
|
|
8
|
+
*/
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Terminal detection
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
/**
|
|
14
|
+
* Detect the appropriate launch mode based on environment variables.
|
|
15
|
+
*
|
|
16
|
+
* Priority:
|
|
17
|
+
* 1. LOOP_LAUNCH_MODE env override (explicit user choice)
|
|
18
|
+
* 2. TMUX_PANE present → "tmux"
|
|
19
|
+
* 3. ITERM_SESSION_ID present → "iterm2"
|
|
20
|
+
* 4. Fallback → "pty" (headless internal PTY)
|
|
21
|
+
*/
|
|
22
|
+
export function detectTerminal() {
|
|
23
|
+
const explicit = (process.env.LOOP_LAUNCH_MODE ?? "").trim().toLowerCase();
|
|
24
|
+
if (explicit === "terminal" || explicit === "tmux" || explicit === "iterm2" || explicit === "pty") {
|
|
25
|
+
return explicit;
|
|
26
|
+
}
|
|
27
|
+
if (process.env.TMUX_PANE)
|
|
28
|
+
return "tmux";
|
|
29
|
+
if (process.env.ITERM_SESSION_ID)
|
|
30
|
+
return "iterm2";
|
|
31
|
+
return "pty";
|
|
32
|
+
}
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// TTY detection
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
function normalizeTty(raw) {
|
|
37
|
+
const trimmed = raw.trim();
|
|
38
|
+
if (!trimmed || trimmed === "not a tty" || trimmed === "/dev/tty") {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
return trimmed;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Detect the TTY device path for the current process.
|
|
45
|
+
* Returns `undefined` when running without a controlling terminal.
|
|
46
|
+
*/
|
|
47
|
+
export function detectTTY() {
|
|
48
|
+
// Allow explicit override for test / sandbox environments
|
|
49
|
+
const override = normalizeTty(process.env.LOOP_TTY_OVERRIDE ?? "");
|
|
50
|
+
if (override)
|
|
51
|
+
return override;
|
|
52
|
+
try {
|
|
53
|
+
const result = spawnSync("tty", {
|
|
54
|
+
stdio: [0, "pipe", "ignore"],
|
|
55
|
+
encoding: "utf8",
|
|
56
|
+
});
|
|
57
|
+
if (result.status === 0 && result.stdout) {
|
|
58
|
+
return normalizeTty(result.stdout);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// tty command unavailable or failed — not fatal
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// tmux pane detection
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
/**
|
|
70
|
+
* Return the current tmux pane identifier (e.g. `%0`, `%1`).
|
|
71
|
+
* Returns `undefined` when not inside a tmux session.
|
|
72
|
+
*/
|
|
73
|
+
export function detectTmuxPane() {
|
|
74
|
+
const pane = process.env.TMUX_PANE;
|
|
75
|
+
return pane ? pane.trim() || undefined : undefined;
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=detect.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iTerm2 adapter.
|
|
3
|
+
*
|
|
4
|
+
* Uses osascript with iTerm2's AppleScript API to create new sessions
|
|
5
|
+
* (tabs / splits) and run commands. Supports activation (bring to front)
|
|
6
|
+
* and text injection via `write text`.
|
|
7
|
+
*
|
|
8
|
+
* Reference: https://iterm2.com/documentation-scripting.html
|
|
9
|
+
*/
|
|
10
|
+
import type { AdapterLaunchOptions, LaunchedProcess, TerminalAdapter, TerminalCapabilities } from "./adapter.js";
|
|
11
|
+
import type { LaunchMode } from "./detect.js";
|
|
12
|
+
export declare class ITerm2Adapter implements TerminalAdapter {
|
|
13
|
+
readonly mode: LaunchMode;
|
|
14
|
+
readonly capabilities: TerminalCapabilities;
|
|
15
|
+
launch(command: string, args: string[], opts: AdapterLaunchOptions): Promise<LaunchedProcess>;
|
|
16
|
+
inject(processOrId: number | string, command: string): Promise<void>;
|
|
17
|
+
activate(_processOrId: number | string): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=iterm2-adapter.d.ts.map
|