loom-agent 1.2.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/.env.example +25 -0
- package/CHANGELOG.md +402 -0
- package/LICENSE +21 -0
- package/LOOM.md +235 -0
- package/README.md +433 -0
- package/bin/loom-tui.js +43 -0
- package/bin/loom.js +44 -0
- package/docs/acp.md +151 -0
- package/docs/web.md +205 -0
- package/package.json +97 -0
- package/scripts/acp-smoke.js +146 -0
- package/src/acp/acp-server.js +287 -0
- package/src/config/provider-cmd.js +37 -0
- package/src/config/settings.js +164 -0
- package/src/core/agents.js +361 -0
- package/src/core/background-tasks.js +103 -0
- package/src/core/cli.js +579 -0
- package/src/core/custom-commands.js +70 -0
- package/src/core/errors.js +29 -0
- package/src/core/events.js +24 -0
- package/src/core/file-diffs.js +282 -0
- package/src/core/format.js +206 -0
- package/src/core/graph.js +257 -0
- package/src/core/hooks.js +82 -0
- package/src/core/lsp.js +385 -0
- package/src/core/memory.js +87 -0
- package/src/core/model-router.js +87 -0
- package/src/core/permissions.js +327 -0
- package/src/core/platform.js +33 -0
- package/src/core/plugin-cmd.js +380 -0
- package/src/core/restore.js +207 -0
- package/src/core/session-store.js +167 -0
- package/src/core/session.js +910 -0
- package/src/core/subagent-log.js +134 -0
- package/src/core/tokens.js +31 -0
- package/src/core/update.js +6 -0
- package/src/core/usage.js +166 -0
- package/src/index.js +41 -0
- package/src/mcp/mcp-client.js +201 -0
- package/src/mcp/mcp-manager.js +193 -0
- package/src/providers/anthropic.js +243 -0
- package/src/providers/google.js +29 -0
- package/src/providers/index.js +175 -0
- package/src/providers/local.js +27 -0
- package/src/providers/nvidia.js +85 -0
- package/src/providers/openai-compat.js +269 -0
- package/src/providers/openai.js +35 -0
- package/src/providers/openrouter.js +43 -0
- package/src/providers/registry.js +196 -0
- package/src/providers/tokenrouter.js +19 -0
- package/src/skills/skill-matcher.js +133 -0
- package/src/skills/skills-manager.js +213 -0
- package/src/tools/index.js +543 -0
- package/src/tui/App.tsx +1578 -0
- package/src/tui/components/BreadcrumbBar.tsx +34 -0
- package/src/tui/components/ChatArea.tsx +518 -0
- package/src/tui/components/InputBar.tsx +354 -0
- package/src/tui/components/MdText.tsx +105 -0
- package/src/tui/components/Modals.tsx +851 -0
- package/src/tui/components/PermissionPopup.tsx +264 -0
- package/src/tui/components/Sidebar.tsx +182 -0
- package/src/tui/components/SplashScreen.tsx +51 -0
- package/src/tui/components/SubagentPanel.tsx +217 -0
- package/src/tui/components/ToastOverlay.tsx +34 -0
- package/src/tui/keybinds.ts +318 -0
- package/src/tui/mcp-presets.ts +189 -0
- package/src/tui/md-render.ts +228 -0
- package/src/tui/store.ts +714 -0
- package/src/tui/suite-home.ts +20 -0
- package/src/tui/theme.ts +313 -0
- package/src/tui/themes.generated.ts +968 -0
- package/src/tui/tool-display.ts +176 -0
- package/src/tui/toolname.ts +60 -0
- package/src/tui/tui-config.ts +28 -0
- package/src/tui-open.tsx +51 -0
- package/src/web/attach.js +242 -0
- package/src/web/graph-view.html +262 -0
- package/src/web/index.html +824 -0
- package/src/web/web-server.js +470 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Data-driven tool display registry — opencode's model, in one place: every
|
|
2
|
+
// tool's chat presentation (icon, pending label, spinner, done label, block
|
|
3
|
+
// rendering, diff/todo behavior) lives HERE as data. The chat renderer never
|
|
4
|
+
// checks tool names; it looks up the display for each tool and falls back to
|
|
5
|
+
// the GenericTool defaults (⚙, "Writing command...", "# {name} {args}" block)
|
|
6
|
+
// for anything it has never seen — MCP servers, custom tools, future tools.
|
|
7
|
+
// That is the agent's freedom: any tool it calls shows up in the chat.
|
|
8
|
+
import { prettyToolName, prettyToolArgs } from "./toolname.ts";
|
|
9
|
+
|
|
10
|
+
export interface ToolDisplay {
|
|
11
|
+
icon: string;
|
|
12
|
+
pending: string;
|
|
13
|
+
// opencode passes spinner=true for exactly read/task/execute; every other
|
|
14
|
+
// tool shows the quiet "~ pending" text while it runs.
|
|
15
|
+
spinner?: boolean;
|
|
16
|
+
// The "done" row label, summarised from the call's args.
|
|
17
|
+
label: (inp: any) => string;
|
|
18
|
+
// Tools that swap their row for a BLOCK once the result is back (opencode's
|
|
19
|
+
// BlockTool components): bash → "$ cmd" with the output, unknown tools →
|
|
20
|
+
// "# {name} {args}" with the output. title builds the block header from the
|
|
21
|
+
// tool part; maxLines caps the output preview (bash 10, generic 3).
|
|
22
|
+
block?: { title: (t: any) => string; maxLines?: number };
|
|
23
|
+
// A finished write/edit renders its diff inline instead of the row.
|
|
24
|
+
diff?: boolean;
|
|
25
|
+
// A done todowrite collapses into the "# Todos" block.
|
|
26
|
+
todos?: boolean;
|
|
27
|
+
// Done/error rows use ✓/✗ (task/execute), not the plain icon.
|
|
28
|
+
check?: boolean;
|
|
29
|
+
// The row is suppressed while the message's subagent panel is up (task).
|
|
30
|
+
subagent?: boolean;
|
|
31
|
+
// Tools that STREAM their output live while running (bash): the chat shows a
|
|
32
|
+
// growing, collapsible terminal block instead of the quiet pending row.
|
|
33
|
+
live?: boolean;
|
|
34
|
+
// How the loop captures what changed for this tool's diff: "file" snapshots
|
|
35
|
+
// the path before/after, "bash" diffs the tree around the command.
|
|
36
|
+
diffs?: "file" | "bash" | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Null-prototype object: names like "constructor"/"toString" must NOT resolve
|
|
40
|
+
// to inherited Object.prototype members — they fall back to the generic entry.
|
|
41
|
+
const TOOL_DISPLAY: Record<string, ToolDisplay> = Object.assign(Object.create(null), {
|
|
42
|
+
bash: {
|
|
43
|
+
icon: "$",
|
|
44
|
+
pending: "Writing command...",
|
|
45
|
+
label: (i) => String(i?.command || "command"),
|
|
46
|
+
block: { title: (t) => "$ " + (t.label || t.name), maxLines: 10 },
|
|
47
|
+
diffs: "bash",
|
|
48
|
+
live: true,
|
|
49
|
+
},
|
|
50
|
+
read: {
|
|
51
|
+
icon: "\u2192",
|
|
52
|
+
pending: "Reading file...",
|
|
53
|
+
spinner: true,
|
|
54
|
+
label: (i) => "Read " + (i?.filePath || "file"),
|
|
55
|
+
diffs: null,
|
|
56
|
+
},
|
|
57
|
+
write: {
|
|
58
|
+
icon: "\u2190",
|
|
59
|
+
pending: "Preparing write...",
|
|
60
|
+
label: (i) => "Write " + (i?.filePath || "file"),
|
|
61
|
+
diff: true,
|
|
62
|
+
diffs: "file",
|
|
63
|
+
},
|
|
64
|
+
edit: {
|
|
65
|
+
icon: "\u2190",
|
|
66
|
+
pending: "Preparing edit...",
|
|
67
|
+
label: (i) => "Edit " + (i?.filePath || "file"),
|
|
68
|
+
diff: true,
|
|
69
|
+
diffs: "file",
|
|
70
|
+
},
|
|
71
|
+
glob: {
|
|
72
|
+
icon: "\u2731",
|
|
73
|
+
pending: "Finding files...",
|
|
74
|
+
label: (i) => "Glob \u0022" + String(i?.pattern || "") + "\u0022",
|
|
75
|
+
diffs: null,
|
|
76
|
+
},
|
|
77
|
+
grep: {
|
|
78
|
+
icon: "\u2731",
|
|
79
|
+
pending: "Searching content...",
|
|
80
|
+
label: (i) => "Grep \u0022" + String(i?.pattern || "") + "\u0022",
|
|
81
|
+
diffs: null,
|
|
82
|
+
},
|
|
83
|
+
webfetch: {
|
|
84
|
+
icon: "%",
|
|
85
|
+
pending: "Fetching from the web...",
|
|
86
|
+
label: (i) => "WebFetch " + (i?.url || "url"),
|
|
87
|
+
diffs: null,
|
|
88
|
+
},
|
|
89
|
+
websearch: {
|
|
90
|
+
icon: "\u25C8",
|
|
91
|
+
pending: "Searching web...",
|
|
92
|
+
label: (i) => "WebSearch \u0022" + String(i?.query || "") + "\u0022",
|
|
93
|
+
diffs: null,
|
|
94
|
+
},
|
|
95
|
+
todowrite: {
|
|
96
|
+
icon: "\u2699",
|
|
97
|
+
pending: "Updating todos...",
|
|
98
|
+
label: () => "Todos",
|
|
99
|
+
todos: true,
|
|
100
|
+
diffs: null,
|
|
101
|
+
},
|
|
102
|
+
task: {
|
|
103
|
+
icon: "\u2502",
|
|
104
|
+
pending: "Delegating...",
|
|
105
|
+
spinner: true,
|
|
106
|
+
check: true,
|
|
107
|
+
subagent: true,
|
|
108
|
+
label: () => "Task",
|
|
109
|
+
diffs: null,
|
|
110
|
+
},
|
|
111
|
+
execute: {
|
|
112
|
+
icon: "\u2502",
|
|
113
|
+
pending: "Delegating...",
|
|
114
|
+
spinner: true,
|
|
115
|
+
check: true,
|
|
116
|
+
label: () => "Execute",
|
|
117
|
+
diffs: null,
|
|
118
|
+
},
|
|
119
|
+
skill: {
|
|
120
|
+
icon: "\u2192",
|
|
121
|
+
pending: "Loading skill...",
|
|
122
|
+
label: (i) => "Skill \u0022" + String(i?.name || "") + "\u0022",
|
|
123
|
+
diffs: null,
|
|
124
|
+
},
|
|
125
|
+
ask: {
|
|
126
|
+
icon: "\u2192",
|
|
127
|
+
pending: "Asking...",
|
|
128
|
+
label: () => "Ask",
|
|
129
|
+
diffs: null,
|
|
130
|
+
},
|
|
131
|
+
question: {
|
|
132
|
+
icon: "\u2192",
|
|
133
|
+
pending: "Asking...",
|
|
134
|
+
label: () => "Ask",
|
|
135
|
+
diffs: null,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// The GenericTool fallback (opencode): ANY unregistered tool renders with the
|
|
140
|
+
// ⚙ icon, "Writing command..." pending, "{name} {args}" label, and an output
|
|
141
|
+
// block once it returns something. No registry entry required.
|
|
142
|
+
function genericDisplay(name: string): ToolDisplay {
|
|
143
|
+
return {
|
|
144
|
+
icon: "\u2699",
|
|
145
|
+
pending: "Writing command...",
|
|
146
|
+
label: (i) => {
|
|
147
|
+
const a = prettyToolArgs(i, 60);
|
|
148
|
+
return name + (a ? " " + a : "");
|
|
149
|
+
},
|
|
150
|
+
block: { title: (t) => "# " + (t.label || t.name), maxLines: 3 },
|
|
151
|
+
diffs: null,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function toolDisplay(name: string): ToolDisplay {
|
|
156
|
+
return Object.prototype.hasOwnProperty.call(TOOL_DISPLAY, name)
|
|
157
|
+
? TOOL_DISPLAY[name]
|
|
158
|
+
: genericDisplay(prettyToolName(name));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Compatibility helpers (App stamps parts with these at call time).
|
|
162
|
+
export function toolIcon(name: string): string {
|
|
163
|
+
return toolDisplay(name).icon;
|
|
164
|
+
}
|
|
165
|
+
export function toolPending(name: string): string {
|
|
166
|
+
return toolDisplay(name).pending;
|
|
167
|
+
}
|
|
168
|
+
export function toolSpinner(name: string): boolean {
|
|
169
|
+
return !!toolDisplay(name).spinner;
|
|
170
|
+
}
|
|
171
|
+
export function toolLabel(name: string, inp: unknown): string {
|
|
172
|
+
return toolDisplay(name).label((inp || {}) as any);
|
|
173
|
+
}
|
|
174
|
+
export function toolIsGeneric(name: string): boolean {
|
|
175
|
+
return !Object.prototype.hasOwnProperty.call(TOOL_DISPLAY, name);
|
|
176
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Generic tool-name/arg formatting shared by the chat and the tool log.
|
|
2
|
+
// Per-tool display data (icons, pending labels, spinners, blocks) lives in
|
|
3
|
+
// tool-display.ts — this module only knows how to FORMAT, never which tool
|
|
4
|
+
// is which.
|
|
5
|
+
|
|
6
|
+
// mcp__memory__read_graph → memory.read_graph · todowrite → todowrite
|
|
7
|
+
export function prettyToolName(name: unknown): string {
|
|
8
|
+
let n = String(name || "");
|
|
9
|
+
if (n.indexOf("mcp__") === 0) {
|
|
10
|
+
const idx = n.indexOf("__", 5);
|
|
11
|
+
if (idx > 0) n = n.slice(5, idx) + "." + n.slice(idx + 2);
|
|
12
|
+
else n = n.slice(5);
|
|
13
|
+
}
|
|
14
|
+
return n;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Credential-like keys are redacted (case-insensitive): token, apiKey/api_key,
|
|
18
|
+
// password, authorization, secret. The underscore filter already drops
|
|
19
|
+
// "_"-prefixed keys; values here must never leak into the chat or log.
|
|
20
|
+
const SECRET_KEY_RX = /(^|[_-])(token|api_?key|password|authorization|secret)([_-]|$)/i;
|
|
21
|
+
|
|
22
|
+
// Compact args for display (opencode's `input()` helper): only primitive
|
|
23
|
+
// values, "key=value" pairs in brackets, capped length. Drops the "_"-prefixed
|
|
24
|
+
// secret-ish keys and nested objects.
|
|
25
|
+
export function prettyToolArgs(inp: unknown, maxLen = 48): string {
|
|
26
|
+
if (!inp || typeof inp !== "object") return "";
|
|
27
|
+
const parts: string[] = [];
|
|
28
|
+
for (const [k, v] of Object.entries(inp as Record<string, unknown>)) {
|
|
29
|
+
if (k.startsWith("_")) continue;
|
|
30
|
+
if (typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean") continue;
|
|
31
|
+
if (SECRET_KEY_RX.test(k)) { parts.push(k + "=[redacted]"); continue; }
|
|
32
|
+
let s = String(v);
|
|
33
|
+
if (s.length > 24) s = s.slice(0, 23) + "\u2026";
|
|
34
|
+
parts.push(k + "=" + s);
|
|
35
|
+
}
|
|
36
|
+
if (!parts.length) return "";
|
|
37
|
+
let s = "[" + parts.join(", ") + "]";
|
|
38
|
+
if (s.length > maxLen) s = s.slice(0, maxLen - 1) + "\u2026";
|
|
39
|
+
return s;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Tool results arrive with terminal escape codes; strip them before the
|
|
43
|
+
// output block renders them (opencode strips ANSI on display too).
|
|
44
|
+
export function stripAnsi(s: string): string {
|
|
45
|
+
return String(s)
|
|
46
|
+
.replace(/\u001b\[[0-9;?]*[ -\/]*[@-~]/g, "")
|
|
47
|
+
.replace(/\u001b\][^\u0007]*(\u0007|\u001b\\)/g, "");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The full lookup row: "⚡ memory.read_graph {"query":"x"}".
|
|
51
|
+
export function formatToolCall(name: unknown, inp: unknown): string {
|
|
52
|
+
const a = prettyToolArgs(inp);
|
|
53
|
+
return "⚡ " + prettyToolName(name) + (a ? " " + a : "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// And the append-only tool log line (same content, for the log box).
|
|
57
|
+
export function formatToolLogLine(name: unknown, inp: unknown): string {
|
|
58
|
+
const a = prettyToolArgs(inp, 60);
|
|
59
|
+
return prettyToolName(name) + (a ? " " + a : "");
|
|
60
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Shared tui.json access (theme.ts, store.ts, keybinds.ts): one path
|
|
2
|
+
// resolution honoring LOOM_CONFIG_DIR, with read-modify-write saves so
|
|
3
|
+
// concurrent updates never clobber each other's fields.
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import os from "os";
|
|
6
|
+
import path from "path";
|
|
7
|
+
|
|
8
|
+
export function tuiStatePath(): string {
|
|
9
|
+
return process.env.LOOM_CONFIG_DIR
|
|
10
|
+
? path.join(process.env.LOOM_CONFIG_DIR, "tui.json")
|
|
11
|
+
: path.join(os.homedir(), ".loom", "tui.json");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function loadTuiJson(): any {
|
|
15
|
+
try {
|
|
16
|
+
const p = tuiStatePath();
|
|
17
|
+
return fs.existsSync(p) ? (JSON.parse(fs.readFileSync(p, "utf8")) || {}) : {};
|
|
18
|
+
} catch { return {}; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function saveTuiJson(patch: any): void {
|
|
22
|
+
try {
|
|
23
|
+
const p = tuiStatePath();
|
|
24
|
+
const data = Object.assign({}, loadTuiJson(), patch);
|
|
25
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
26
|
+
fs.writeFileSync(p, JSON.stringify(data, null, 2));
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
package/src/tui-open.tsx
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Loom Code — OpenTUI entry point.
|
|
3
|
+
// Usage: bun run src/tui-open.tsx [prompt...] OR bun src/tui-open.tsx -s <session-id>
|
|
4
|
+
import { render } from "@opentui/solid";
|
|
5
|
+
import { App } from "./tui/App.tsx";
|
|
6
|
+
import { defaultMcpInstall } from "./core/plugin-cmd.js";
|
|
7
|
+
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
|
|
10
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
11
|
+
console.log("loom-code v1.2.0");
|
|
12
|
+
process.exit(0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
16
|
+
console.log("loom - AI coding agent (OpenTUI edition)");
|
|
17
|
+
console.log("Usage: bun run src/tui-open.tsx [prompt...]");
|
|
18
|
+
console.log(" bun run src/tui-open.tsx Start interactive TUI");
|
|
19
|
+
console.log(" bun run src/tui-open.tsx \"prompt\" Start with prompt");
|
|
20
|
+
console.log(" bun run src/tui-open.tsx -s <id> Resume session");
|
|
21
|
+
console.log(" bun run src/tui-open.tsx -p \"q\" Print mode (one-shot)");
|
|
22
|
+
console.log(" bun run src/tui-open.tsx --auto Auto-approve permissions");
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const pIdx = args.indexOf("-p");
|
|
27
|
+
const printMode = pIdx !== -1;
|
|
28
|
+
const sIdx = args.indexOf("-s");
|
|
29
|
+
const sessionId = sIdx !== -1 ? args[sIdx + 1] : null;
|
|
30
|
+
const autoMode = args.includes("--auto") || args.includes("-a");
|
|
31
|
+
const initialPrompt = args.filter((a, i) => !a.startsWith("-") && (sIdx === -1 || i !== sIdx + 1)).join(" ");
|
|
32
|
+
|
|
33
|
+
defaultMcpInstall();
|
|
34
|
+
|
|
35
|
+
if (printMode) {
|
|
36
|
+
const { Session } = require("./core/session.js");
|
|
37
|
+
const sess = new Session();
|
|
38
|
+
if (autoMode) sess.permissions.setAuto(true);
|
|
39
|
+
const query = args[pIdx + 1] || initialPrompt || "Hello";
|
|
40
|
+
const resp = await sess.sendUserMessage(query);
|
|
41
|
+
if (resp.type === "text") console.log(resp.content);
|
|
42
|
+
else console.error(resp.content || "(error)");
|
|
43
|
+
process.exit(resp.type === "error" ? 1 : 0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
render(() => <App initialPrompt={initialPrompt} resumeSession={sessionId} autoMode={autoMode} />, {
|
|
47
|
+
targetFps: 60,
|
|
48
|
+
useMouse: true,
|
|
49
|
+
autoFocus: true,
|
|
50
|
+
exitOnCtrlC: false,
|
|
51
|
+
});
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// loom attach <url> — terminal client for a running `loom web` server. Shares
|
|
2
|
+
// the same sessions and state: pick an existing session (or create a new one),
|
|
3
|
+
// then chat from the terminal. Streaming is plain Server-Sent Events parsed
|
|
4
|
+
// from a fetch ReadableStream.
|
|
5
|
+
//
|
|
6
|
+
// loom attach http://localhost:4096
|
|
7
|
+
// loom attach http://loom.local:80
|
|
8
|
+
// LOOM_SERVER_PASSWORD=... loom attach http://localhost:4096
|
|
9
|
+
// loom attach http://localhost:4096 --username me --password secret
|
|
10
|
+
//
|
|
11
|
+
// This is a line-mode attach (not the full OpenTUI): the SolidJS TUI runs in-
|
|
12
|
+
// process and isn't rewired to a remote server yet, so terminal attach is the
|
|
13
|
+
// honest v1 that actually shares state with the browser.
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const readline = require('readline');
|
|
17
|
+
|
|
18
|
+
const COLOR = {
|
|
19
|
+
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
20
|
+
blue: '\x1b[34m', gray: '\x1b[90m', cyan: '\x1b[36m',
|
|
21
|
+
yellow: '\x1b[33m', red: '\x1b[31m', green: '\x1b[32m',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function parseArgs(argv) {
|
|
25
|
+
const out = { url: null, username: process.env.LOOM_SERVER_USERNAME || null, password: process.env.LOOM_SERVER_PASSWORD || null, sessionId: null };
|
|
26
|
+
for (let i = 0; i < argv.length; i++) {
|
|
27
|
+
const a = argv[i];
|
|
28
|
+
if (a === '--username' || a === '-u') out.username = argv[++i];
|
|
29
|
+
else if (a === '--password' || a === '-p') out.password = argv[++i];
|
|
30
|
+
else if (a === '--session' || a === '-s') out.sessionId = argv[++i];
|
|
31
|
+
else if (a === '--help' || a === '-h') out.help = true;
|
|
32
|
+
else if (!a.startsWith('-') && !out.url) out.url = a;
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function jsonFetch(client, path, options) {
|
|
38
|
+
const opts = options ? { ...options, headers: { ...(options.headers || {}), ...(client.cookie ? { Cookie: client.cookie } : {}) } }
|
|
39
|
+
: (client.cookie ? { headers: { Cookie: client.cookie } } : undefined);
|
|
40
|
+
const res = await fetch(new URL(path, client.base), opts);
|
|
41
|
+
if (res.status === 401) {
|
|
42
|
+
const err = new Error('unauthorized');
|
|
43
|
+
err.unauthorized = true;
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
const ct = res.headers.get('content-type') || '';
|
|
47
|
+
const body = ct.includes('json') ? await res.json() : await res.text();
|
|
48
|
+
if (!res.ok) throw new Error(body && body.error ? body.error : res.statusText);
|
|
49
|
+
return body;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function ensureAuth(client) {
|
|
53
|
+
// Try fetching /api/auth: if required and we have credentials, POST /api/auth/login.
|
|
54
|
+
let info;
|
|
55
|
+
try { info = await jsonFetch(client, '/api/auth'); } catch (e) { if (e.unauthorized) throw e; info = { required: false }; }
|
|
56
|
+
if (!info.required) { client.cookie = null; return; }
|
|
57
|
+
if (!client.username || !client.password) {
|
|
58
|
+
console.error('Server is password-protected. Pass --username and --password, or set LOOM_SERVER_USERNAME / LOOM_SERVER_PASSWORD.');
|
|
59
|
+
process.exit(2);
|
|
60
|
+
}
|
|
61
|
+
const res = await fetch(new URL('/api/auth', client.base), {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { 'Content-Type': 'application/json' },
|
|
64
|
+
body: JSON.stringify({ username: client.username, password: client.password }),
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
console.error('Login failed (' + res.status + ').');
|
|
68
|
+
process.exit(2);
|
|
69
|
+
}
|
|
70
|
+
const sc = res.headers.get('set-cookie') || '';
|
|
71
|
+
const token = /(?:^|;\s*)loom_token=([^;\s]+)/.exec(sc)?.[1];
|
|
72
|
+
client.cookie = token ? 'loom_token=' + token : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function makeClient(args) {
|
|
76
|
+
if (!args.url) { console.error('Usage: loom attach <url> [--username U --password P] [--session ID]'); process.exit(2); }
|
|
77
|
+
let u;
|
|
78
|
+
try { u = new URL(args.url); } catch { console.error('Invalid URL: ' + args.url); process.exit(2); }
|
|
79
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') { console.error('Only http/https URLs are supported.'); process.exit(2); }
|
|
80
|
+
return { base: u, cookie: null, username: args.username, password: args.password };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function chooseSession(client) {
|
|
84
|
+
const { sessions } = await jsonFetch(client, '/api/sessions');
|
|
85
|
+
if (!sessions.length) {
|
|
86
|
+
console.log(COLOR.gray + '(no saved sessions — a new chat will be created)' + COLOR.reset);
|
|
87
|
+
return { id: null };
|
|
88
|
+
}
|
|
89
|
+
console.log(COLOR.bold + 'Sessions' + COLOR.reset);
|
|
90
|
+
sessions.forEach((s, i) => {
|
|
91
|
+
const when = s.createdAt ? new Date(s.createdAt).toLocaleString() : 'unknown';
|
|
92
|
+
const meta = [when, (s.messageCount || 0) + ' msgs', s.provider ? s.provider : '', s.model ? s.model : ''].filter(Boolean).join(' · ');
|
|
93
|
+
console.log(' ' + COLOR.cyan + (i + 1) + COLOR.reset + ') ' + s.id + ' ' + COLOR.gray + meta + COLOR.reset + (s.active ? ' ' + COLOR.green + '(active)' + COLOR.reset : ''));
|
|
94
|
+
});
|
|
95
|
+
console.log(' ' + COLOR.cyan + '0' + COLOR.reset + ') New chat');
|
|
96
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
97
|
+
const ans = await new Promise((resolve) => rl.question(COLOR.bold + 'Choose [0-' + sessions.length + ']: ' + COLOR.reset, (v) => { rl.close(); resolve(v.trim()); }));
|
|
98
|
+
const n = parseInt(ans, 10);
|
|
99
|
+
if (!isNaN(n) && n >= 1 && n <= sessions.length) {
|
|
100
|
+
const chosen = sessions[n - 1];
|
|
101
|
+
return { id: chosen.id, transcript: chosen };
|
|
102
|
+
}
|
|
103
|
+
return { id: null };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function loadTranscript(client, id) {
|
|
107
|
+
try {
|
|
108
|
+
const { session } = await jsonFetch(client, '/api/sessions/' + id);
|
|
109
|
+
if (session && session.messages && session.messages.length) {
|
|
110
|
+
console.log(COLOR.gray + '\nTranscript (' + session.messages.length + ' messages):' + COLOR.reset);
|
|
111
|
+
for (const m of session.messages) {
|
|
112
|
+
const who = m.role === 'user' ? COLOR.blue + 'you' : COLOR.green + 'loom';
|
|
113
|
+
const content = String(m.content || '');
|
|
114
|
+
console.log(' ' + who + COLOR.reset + ': ' + content.split('\n').join('\n '));
|
|
115
|
+
}
|
|
116
|
+
console.log('');
|
|
117
|
+
}
|
|
118
|
+
} catch (e) {
|
|
119
|
+
if (!e.unauthorized) console.error(COLOR.gray + '(could not load transcript: ' + e.message + ')' + COLOR.reset);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function createSession(client) {
|
|
124
|
+
const { id } = await jsonFetch(client, '/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'build' }) });
|
|
125
|
+
return id;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function streamChat(client, id, text, rlState) {
|
|
129
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
130
|
+
if (client.cookie) headers.Cookie = client.cookie;
|
|
131
|
+
let res;
|
|
132
|
+
try {
|
|
133
|
+
res = await fetch(new URL('/api/chat', client.base), {
|
|
134
|
+
method: 'POST', headers,
|
|
135
|
+
body: JSON.stringify({ id, message: text }),
|
|
136
|
+
});
|
|
137
|
+
} catch (e) {
|
|
138
|
+
console.error(COLOR.red + 'Network error: ' + e.message + COLOR.reset);
|
|
139
|
+
return { error: e.message };
|
|
140
|
+
}
|
|
141
|
+
if (res.status === 401) { ensureAuth(client).catch(() => {}); return { unauthorized: true }; }
|
|
142
|
+
if (!res.ok) { const body = await res.text().catch(() => ''); console.error(COLOR.red + 'Server ' + res.status + ': ' + body + COLOR.reset); return { error: body }; }
|
|
143
|
+
|
|
144
|
+
const reader = res.body.getReader();
|
|
145
|
+
const dec = new TextDecoder();
|
|
146
|
+
let buf = '';
|
|
147
|
+
let text_started = false;
|
|
148
|
+
process.stdout.write(COLOR.green + 'loom' + COLOR.reset + ': ');
|
|
149
|
+
const flushTextStart = () => { if (!text_started) { text_started = true; } };
|
|
150
|
+
const newline = () => { if (text_started) process.stdout.write('\n'); };
|
|
151
|
+
for (;;) {
|
|
152
|
+
const { value, done } = await reader.read();
|
|
153
|
+
if (done) break;
|
|
154
|
+
buf += dec.decode(value, { stream: true });
|
|
155
|
+
let idx;
|
|
156
|
+
while ((idx = buf.indexOf('\n\n')) !== -1) {
|
|
157
|
+
const raw = buf.slice(0, idx); buf = buf.slice(idx + 2);
|
|
158
|
+
const line = raw.split('\n').find((l) => l.startsWith('data: '));
|
|
159
|
+
if (!line) continue;
|
|
160
|
+
let ev; try { ev = JSON.parse(line.slice(6)); } catch { continue; }
|
|
161
|
+
switch (ev.type) {
|
|
162
|
+
case 'delta': flushTextStart(); process.stdout.write(ev.text || ''); break;
|
|
163
|
+
case 'reasoning': flushTextStart(); process.stdout.write(COLOR.gray + (ev.text || '') + COLOR.reset); break;
|
|
164
|
+
case 'tool.use':
|
|
165
|
+
newline();
|
|
166
|
+
console.log(COLOR.dim + ' ↳ tool: ' + COLOR.yellow + ev.name + COLOR.reset + COLOR.dim + ' ' + (() => { try { return JSON.stringify(ev.input); } catch { return ''; } })().slice(0, 200) + COLOR.reset);
|
|
167
|
+
break;
|
|
168
|
+
case 'tool.result':
|
|
169
|
+
newline();
|
|
170
|
+
console.log(COLOR.dim + ' ↳ result: ' + String(ev.result != null ? (typeof ev.result === 'string' ? ev.result : JSON.stringify(ev.result)) : '').slice(0, 240) + COLOR.reset);
|
|
171
|
+
break;
|
|
172
|
+
case 'request.error': newline(); console.error(COLOR.red + 'error: ' + (ev.message || '') + COLOR.reset); return { error: ev.message };
|
|
173
|
+
case 'request.completed': newline(); break;
|
|
174
|
+
case 'done': newline(); return { ok: true };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
newline();
|
|
179
|
+
return { ok: true };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function cancelCurrent(client, id) {
|
|
183
|
+
try {
|
|
184
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
185
|
+
if (client.cookie) headers.Cookie = client.cookie;
|
|
186
|
+
await fetch(new URL('/api/cancel', client.base), { method: 'POST', headers, body: JSON.stringify({ id }) });
|
|
187
|
+
} catch {}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function main() {
|
|
191
|
+
const args = parseArgs(process.argv.slice(2));
|
|
192
|
+
if (args.help) {
|
|
193
|
+
console.log('Usage: loom attach <url> [options]');
|
|
194
|
+
console.log(' --username, -u Username for password-protected server');
|
|
195
|
+
console.log(' --password, -p Password (or set LOOM_SERVER_PASSWORD)');
|
|
196
|
+
console.log(' --session, -s Resume a specific session id without prompting');
|
|
197
|
+
process.exit(0);
|
|
198
|
+
}
|
|
199
|
+
const client = makeClient(args);
|
|
200
|
+
await ensureAuth(client);
|
|
201
|
+
|
|
202
|
+
// Try to verify the server is reachable.
|
|
203
|
+
try {
|
|
204
|
+
const { ok } = await jsonFetch(client, '/api/health');
|
|
205
|
+
if (!ok) throw new Error('health failed');
|
|
206
|
+
} catch (e) {
|
|
207
|
+
if (e.unauthorized) { /* auth handled above; ok */ }
|
|
208
|
+
else { console.error(COLOR.red + 'Cannot reach ' + client.base + ' (' + e.message + ').' + COLOR.reset); process.exit(2); }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let sessionId = args.sessionId || null;
|
|
212
|
+
if (!sessionId) {
|
|
213
|
+
const pick = await chooseSession(client);
|
|
214
|
+
sessionId = pick.id;
|
|
215
|
+
if (!sessionId) sessionId = await createSession(client);
|
|
216
|
+
}
|
|
217
|
+
console.log(COLOR.gray + 'Attached to ' + client.base + ' — session ' + sessionId + COLOR.reset);
|
|
218
|
+
if (!args.sessionId) await loadTranscript(client, sessionId);
|
|
219
|
+
|
|
220
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
221
|
+
let busy = false;
|
|
222
|
+
let stopping = false;
|
|
223
|
+
rl.on('SIGINT', () => {
|
|
224
|
+
if (busy) { stopping = true; cancelCurrent(client, sessionId).catch(() => {}); console.log(COLOR.gray + '\n(stopping)' + COLOR.reset); }
|
|
225
|
+
else { console.log(COLOR.gray + '\nbye.' + COLOR.reset); rl.close(); process.exit(0); }
|
|
226
|
+
});
|
|
227
|
+
const ask = () => new Promise((resolve) => rl.question(COLOR.bold + '> ' + COLOR.reset, (v) => resolve(v.trim())));
|
|
228
|
+
for (;;) {
|
|
229
|
+
let text;
|
|
230
|
+
try { text = await ask(); } catch { break; }
|
|
231
|
+
if (!text) continue;
|
|
232
|
+
if (text === '/exit' || text === ':q') { console.log(COLOR.gray + 'bye.' + COLOR.reset); break; }
|
|
233
|
+
busy = true; stopping = false;
|
|
234
|
+
console.log(COLOR.blue + 'you' + COLOR.reset + ': ' + text);
|
|
235
|
+
await streamChat(client, sessionId, text, {});
|
|
236
|
+
busy = false;
|
|
237
|
+
}
|
|
238
|
+
rl.close();
|
|
239
|
+
process.exit(0);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = { main };
|