@rse/ase 0.0.19 → 0.0.21
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/dst/ase-claude.js +116 -0
- package/dst/ase-diagram.js +49 -4
- package/dst/ase-launch.js +109 -0
- package/dst/ase-mcp.js +79 -30
- package/dst/ase-service.js +26 -8
- package/dst/ase-setup.js +43 -21
- package/dst/ase-statusline.js +399 -65
- package/package.json +7 -8
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/*
|
|
2
|
+
** Agentic Software Engineering (ASE)
|
|
3
|
+
** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
|
+
** Licensed under GPL 3.0 <https://spdx.org/licenses/GPL-3.0-only>
|
|
5
|
+
*/
|
|
6
|
+
import { execa, execaSync } from "execa";
|
|
7
|
+
import which from "which";
|
|
8
|
+
import { Config, configSchema, parseScope } from "./ase-config.js";
|
|
9
|
+
/* default statusline arguments (claudeX fallback) used when the
|
|
10
|
+
"agent.statusline" config variable is empty or unset */
|
|
11
|
+
const DEFAULT_STATUSLINE_ARGS = "-w 0 -m 2 '<blue>%u</blue> <red>%p</red> <black>%T</black> %s' '%m %e %t' '%P %c'";
|
|
12
|
+
/* CLI command "ase claude" */
|
|
13
|
+
export default class ClaudeCommand {
|
|
14
|
+
log;
|
|
15
|
+
constructor(log) {
|
|
16
|
+
this.log = log;
|
|
17
|
+
}
|
|
18
|
+
/* ensure a tool is available */
|
|
19
|
+
async ensureTool(tool) {
|
|
20
|
+
return which(tool).catch(() => {
|
|
21
|
+
throw new Error(`mandatory tool "${tool}" not found in $PATH`);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/* resolve the statusline command-line arguments from the layered
|
|
25
|
+
ASE configuration ("agent.statusline"), falling back to the
|
|
26
|
+
claudeX default if the value is empty or unavailable */
|
|
27
|
+
resolveStatuslineArgs() {
|
|
28
|
+
let args = "";
|
|
29
|
+
try {
|
|
30
|
+
const cfg = new Config("config", configSchema, this.log, parseScope("project"));
|
|
31
|
+
cfg.read("lenient");
|
|
32
|
+
args = String(cfg.get("agent.statusline") ?? "").trim();
|
|
33
|
+
}
|
|
34
|
+
catch (_e) {
|
|
35
|
+
/* cascade unavailable; keep fallback */
|
|
36
|
+
}
|
|
37
|
+
if (args === "")
|
|
38
|
+
args = DEFAULT_STATUSLINE_ARGS;
|
|
39
|
+
return args;
|
|
40
|
+
}
|
|
41
|
+
/* register commands */
|
|
42
|
+
register(program) {
|
|
43
|
+
program
|
|
44
|
+
.command("claude")
|
|
45
|
+
.description("start Claude Code with bootstrapped ASE environment and settings")
|
|
46
|
+
.passThroughOptions()
|
|
47
|
+
.allowUnknownOption()
|
|
48
|
+
.argument("[args...]", "arguments forwarded verbatim to the \"claude\" CLI")
|
|
49
|
+
.action(async (args) => {
|
|
50
|
+
/* ensure Claude Code CLI is available */
|
|
51
|
+
await this.ensureTool("claude");
|
|
52
|
+
/* bootstrap ASE_TERM_WIDTH from terminal columns */
|
|
53
|
+
if (process.env.ASE_TERM_WIDTH === undefined) {
|
|
54
|
+
let width = 0;
|
|
55
|
+
if (process.stdout.isTTY) {
|
|
56
|
+
const cols = process.stdout.columns;
|
|
57
|
+
if (typeof cols === "number" && cols > 0)
|
|
58
|
+
width = cols;
|
|
59
|
+
}
|
|
60
|
+
process.env.ASE_TERM_WIDTH = `${width}`;
|
|
61
|
+
}
|
|
62
|
+
/* bootstrap ASE_TERM_HEIGHT from terminal rows */
|
|
63
|
+
if (process.env.ASE_TERM_HEIGHT === undefined) {
|
|
64
|
+
let height = 0;
|
|
65
|
+
if (process.stdout.isTTY) {
|
|
66
|
+
const rows = process.stdout.rows;
|
|
67
|
+
if (typeof rows === "number" && rows > 0)
|
|
68
|
+
height = rows;
|
|
69
|
+
}
|
|
70
|
+
process.env.ASE_TERM_HEIGHT = `${height}`;
|
|
71
|
+
}
|
|
72
|
+
/* bootstrap ASE_TERM_COLORS from "tput colors" */
|
|
73
|
+
if (process.env.ASE_TERM_COLORS === undefined) {
|
|
74
|
+
let colorMode = "none";
|
|
75
|
+
try {
|
|
76
|
+
const { stdout } = execaSync("tput", ["colors"], { reject: false });
|
|
77
|
+
const n = parseInt(stdout.trim(), 10);
|
|
78
|
+
if (!Number.isNaN(n) && n >= 256)
|
|
79
|
+
colorMode = "ansi256";
|
|
80
|
+
else if (!Number.isNaN(n) && n >= 16)
|
|
81
|
+
colorMode = "ansi16";
|
|
82
|
+
}
|
|
83
|
+
catch (_e) {
|
|
84
|
+
/* ignore */
|
|
85
|
+
}
|
|
86
|
+
process.env.ASE_TERM_COLORS = colorMode;
|
|
87
|
+
}
|
|
88
|
+
/* resolve statusline arguments (config or claudeX fallback) */
|
|
89
|
+
const statuslineArgs = this.resolveStatuslineArgs();
|
|
90
|
+
/* build inline Claude Code settings JSON */
|
|
91
|
+
const settings = {
|
|
92
|
+
env: {
|
|
93
|
+
DISABLE_TELEMETRY: "1",
|
|
94
|
+
DISABLE_AUTOUPDATER: "1",
|
|
95
|
+
DISABLE_BUG_COMMAND: "1",
|
|
96
|
+
DISABLE_ERROR_REPORTING: "1"
|
|
97
|
+
},
|
|
98
|
+
statusLine: {
|
|
99
|
+
type: "command",
|
|
100
|
+
command: `ase statusline ${statuslineArgs}`,
|
|
101
|
+
padding: 0
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const settingsJSON = JSON.stringify(settings);
|
|
105
|
+
/* exec Claude Code with the inline settings prepended
|
|
106
|
+
and any user-supplied arguments forwarded verbatim */
|
|
107
|
+
const result = await execa("claude", ["--settings", settingsJSON, ...args], {
|
|
108
|
+
stdio: "inherit",
|
|
109
|
+
env: process.env,
|
|
110
|
+
reject: false,
|
|
111
|
+
windowsHide: false
|
|
112
|
+
});
|
|
113
|
+
process.exit(result.exitCode ?? 0);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
package/dst/ase-diagram.js
CHANGED
|
@@ -20,7 +20,7 @@ const parseColorMode = (name) => (value) => {
|
|
|
20
20
|
return value;
|
|
21
21
|
};
|
|
22
22
|
/* detect terminal column width */
|
|
23
|
-
const detectTermWidth = () => {
|
|
23
|
+
export const detectTermWidth = () => {
|
|
24
24
|
let width = 0;
|
|
25
25
|
/* attempt 1: query environment variable */
|
|
26
26
|
if (process.env.ASE_TERM_WIDTH !== undefined) {
|
|
@@ -37,7 +37,7 @@ const detectTermWidth = () => {
|
|
|
37
37
|
return width;
|
|
38
38
|
};
|
|
39
39
|
/* detect terminal row height */
|
|
40
|
-
const detectTermHeight = () => {
|
|
40
|
+
export const detectTermHeight = () => {
|
|
41
41
|
let height = 0;
|
|
42
42
|
/* attempt 1: query environment variable */
|
|
43
43
|
if (process.env.ASE_TERM_HEIGHT !== undefined) {
|
|
@@ -112,6 +112,29 @@ const truncateAnsiLine = (line, budget) => {
|
|
|
112
112
|
out += "\x1b[0m";
|
|
113
113
|
return out;
|
|
114
114
|
};
|
|
115
|
+
/* measure visible column width of a rendered line, ignoring ANSI escape
|
|
116
|
+
sequences (CSI ...m); mirrors the visibility model of truncateAnsiLine */
|
|
117
|
+
const visibleWidth = (line) => {
|
|
118
|
+
let visible = 0;
|
|
119
|
+
let i = 0;
|
|
120
|
+
while (i < line.length) {
|
|
121
|
+
const ch = line[i];
|
|
122
|
+
if (ch === "\x1b" && line[i + 1] === "[") {
|
|
123
|
+
let j = i + 2;
|
|
124
|
+
while (j < line.length && !/[A-Za-z]/.test(line[j]))
|
|
125
|
+
j++;
|
|
126
|
+
if (j < line.length) {
|
|
127
|
+
i = j + 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
i++;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
visible++;
|
|
134
|
+
i++;
|
|
135
|
+
}
|
|
136
|
+
return visible;
|
|
137
|
+
};
|
|
115
138
|
/* pure rendering helper: turn a Mermaid source string plus options into
|
|
116
139
|
a rendered Unicode/ASCII diagram string. Throws on render failure. */
|
|
117
140
|
export const renderDiagram = (src, opts) => {
|
|
@@ -146,11 +169,33 @@ export const renderDiagram = (src, opts) => {
|
|
|
146
169
|
const maxHeight = termHeight > 0 ? termHeight - opts.diagramClipY : 0;
|
|
147
170
|
const trailingNL = out.endsWith("\n");
|
|
148
171
|
let lines = (trailingNL ? out.slice(0, -1) : out).split("\n");
|
|
149
|
-
|
|
172
|
+
let widthWarn = "";
|
|
173
|
+
let heightWarn = "";
|
|
174
|
+
if (maxWidth > 0) {
|
|
175
|
+
const widest = lines.reduce((m, l) => Math.max(m, visibleWidth(l)), 0);
|
|
176
|
+
if (widest > maxWidth)
|
|
177
|
+
widthWarn =
|
|
178
|
+
`ase diagram: WARNING: rendered diagram width ${widest} exceeds budget ${maxWidth}; ` +
|
|
179
|
+
"rightmost content was clipped. Please regenerate the Mermaid source to fit " +
|
|
180
|
+
`within ${maxWidth} chars by preferring a portrait orientation ` +
|
|
181
|
+
"(\"flowchart TB\", top-to-bottom) over landscape (\"LR\"/\"RL\"/\"BT\"), " +
|
|
182
|
+
"reducing siblings per row, abbreviating node labels, or restructuring " +
|
|
183
|
+
"into nested subgraph hierarchies.";
|
|
150
184
|
lines = lines.map((l) => truncateAnsiLine(l, maxWidth));
|
|
151
|
-
|
|
185
|
+
}
|
|
186
|
+
if (maxHeight > 0 && lines.length > maxHeight) {
|
|
187
|
+
const overflow = lines.length - maxHeight;
|
|
188
|
+
heightWarn =
|
|
189
|
+
`ase diagram: WARNING: rendered diagram height ${lines.length} exceeds budget ${maxHeight}; ` +
|
|
190
|
+
`bottom ${overflow} line(s) were clipped. Please regenerate the Mermaid source to fit ` +
|
|
191
|
+
`within ${maxHeight} lines by reducing depth or splitting into multiple diagrams.`;
|
|
152
192
|
lines = lines.slice(0, maxHeight);
|
|
193
|
+
}
|
|
153
194
|
out = lines.join("\n") + (trailingNL ? "\n" : "");
|
|
195
|
+
if (widthWarn !== "")
|
|
196
|
+
out += "\n" + widthWarn + "\n";
|
|
197
|
+
if (heightWarn !== "")
|
|
198
|
+
out += "\n" + heightWarn + "\n";
|
|
154
199
|
}
|
|
155
200
|
return out;
|
|
156
201
|
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/*
|
|
2
|
+
** Agentic Software Engineering (ASE)
|
|
3
|
+
** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
|
+
** Licensed under GPL 3.0 <https://spdx.org/licenses/GPL-3.0-only>
|
|
5
|
+
*/
|
|
6
|
+
import { execa, execaSync } from "execa";
|
|
7
|
+
import which from "which";
|
|
8
|
+
import { Config, configSchema, parseScope } from "./ase-config.js";
|
|
9
|
+
/* list of supported tools for "ase launch <tool>" */
|
|
10
|
+
const SUPPORTED_TOOLS = ["claude"];
|
|
11
|
+
/* default statusline arguments (used when "agent.statusline" is empty/unset) */
|
|
12
|
+
const DEFAULT_STATUSLINE_ARGS = "-w 0 -m 2 '<blue>%u</blue> <red>%p</red> <black>%T</black> %s' '%m %e %t' '%P %c'";
|
|
13
|
+
/* CLI command "ase launch" */
|
|
14
|
+
export default class LaunchCommand {
|
|
15
|
+
log;
|
|
16
|
+
constructor(log) {
|
|
17
|
+
this.log = log;
|
|
18
|
+
}
|
|
19
|
+
/* resolve the effective "agent.statusline" from the layered
|
|
20
|
+
configuration cascade (default < user < project) */
|
|
21
|
+
readStatuslineConfig() {
|
|
22
|
+
let statusline = "";
|
|
23
|
+
try {
|
|
24
|
+
const cfg = new Config("config", configSchema, this.log, parseScope(undefined));
|
|
25
|
+
cfg.read("lenient");
|
|
26
|
+
statusline = String(cfg.get("agent.statusline") ?? "").trim();
|
|
27
|
+
}
|
|
28
|
+
catch (_e) {
|
|
29
|
+
/* cascade unavailable; leave default */
|
|
30
|
+
}
|
|
31
|
+
/* reject shell metacharacters to prevent command injection through
|
|
32
|
+
the configured value when later interpolated into a shell command */
|
|
33
|
+
if (statusline !== "" && /[;&|`$\n\r]/.test(statusline))
|
|
34
|
+
throw new Error("invalid \"agent.statusline\" configuration value: " +
|
|
35
|
+
"must not contain shell metacharacters (; & | ` $ newline)");
|
|
36
|
+
return statusline;
|
|
37
|
+
}
|
|
38
|
+
/* populate ASE_TERM_* environment variables (only if unset),
|
|
39
|
+
so downstream tools (e.g. the diagram renderer) get reliable
|
|
40
|
+
terminal sizing/color information regardless of the launched tool */
|
|
41
|
+
populateTermEnv() {
|
|
42
|
+
const tty = process.stdout.isTTY;
|
|
43
|
+
if (process.env.ASE_TERM_WIDTH === undefined) {
|
|
44
|
+
const cols = tty ? process.stdout.columns : 0;
|
|
45
|
+
process.env.ASE_TERM_WIDTH = `${typeof cols === "number" && cols > 0 ? cols : 0}`;
|
|
46
|
+
}
|
|
47
|
+
if (process.env.ASE_TERM_HEIGHT === undefined) {
|
|
48
|
+
const rows = tty ? process.stdout.rows : 0;
|
|
49
|
+
process.env.ASE_TERM_HEIGHT = `${typeof rows === "number" && rows > 0 ? rows : 0}`;
|
|
50
|
+
}
|
|
51
|
+
if (process.env.ASE_TERM_COLORS === undefined) {
|
|
52
|
+
let colorMode = "none";
|
|
53
|
+
try {
|
|
54
|
+
const { stdout } = execaSync("tput", ["colors"], { reject: false });
|
|
55
|
+
const n = parseInt(stdout.trim(), 10);
|
|
56
|
+
if (!Number.isNaN(n) && n >= 256)
|
|
57
|
+
colorMode = "ansi256";
|
|
58
|
+
else if (!Number.isNaN(n) && n >= 16)
|
|
59
|
+
colorMode = "ansi16";
|
|
60
|
+
}
|
|
61
|
+
catch (_e) {
|
|
62
|
+
/* ignore */
|
|
63
|
+
}
|
|
64
|
+
process.env.ASE_TERM_COLORS = colorMode;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/* handler for "ase launch <tool> [<options>...]" */
|
|
68
|
+
async run(tool, args) {
|
|
69
|
+
/* populate ASE_TERM_* environment variables (always, regardless of tool) */
|
|
70
|
+
this.populateTermEnv();
|
|
71
|
+
/* dispatch by tool name */
|
|
72
|
+
if (tool === "claude") {
|
|
73
|
+
await which("claude").catch(() => {
|
|
74
|
+
throw new Error("required tool \"claude\" not found in $PATH");
|
|
75
|
+
});
|
|
76
|
+
let statusline = this.readStatuslineConfig();
|
|
77
|
+
if (statusline === "")
|
|
78
|
+
statusline = DEFAULT_STATUSLINE_ARGS;
|
|
79
|
+
const settingsJson = JSON.stringify({
|
|
80
|
+
statusLine: {
|
|
81
|
+
type: "command",
|
|
82
|
+
command: `ase statusline ${statusline}`,
|
|
83
|
+
padding: 0
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
const result = await execa("claude", ["--settings", settingsJson, ...args], {
|
|
87
|
+
stdio: "inherit",
|
|
88
|
+
env: process.env,
|
|
89
|
+
reject: false
|
|
90
|
+
});
|
|
91
|
+
return result.exitCode ?? 0;
|
|
92
|
+
}
|
|
93
|
+
else
|
|
94
|
+
throw new Error(`unsupported tool "${tool}" ` +
|
|
95
|
+
`(expected one of: ${SUPPORTED_TOOLS.join(", ")})`);
|
|
96
|
+
}
|
|
97
|
+
/* register commands */
|
|
98
|
+
register(program) {
|
|
99
|
+
program
|
|
100
|
+
.command("launch <tool> [options...]")
|
|
101
|
+
.description("launch a supported tool with bootstrapped environment and settings " +
|
|
102
|
+
`(supported tools: ${SUPPORTED_TOOLS.join(", ")})`)
|
|
103
|
+
.passThroughOptions()
|
|
104
|
+
.allowUnknownOption()
|
|
105
|
+
.action(async (tool, options) => {
|
|
106
|
+
process.exit(await this.run(tool, options));
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
package/dst/ase-mcp.js
CHANGED
|
@@ -86,54 +86,102 @@ export default class MCPCommand {
|
|
|
86
86
|
/* bridge stdio to a Streamable HTTP MCP endpoint on the local service */
|
|
87
87
|
async runBridge() {
|
|
88
88
|
/* ensure the service is running */
|
|
89
|
-
|
|
90
|
-
/* create MCP
|
|
91
|
-
const url = new URL(`http://${HOST}:${port}/mcp`);
|
|
92
|
-
const client = new StreamableHTTPClientTransport(url);
|
|
93
|
-
/* create MCP STDIO server */
|
|
89
|
+
let { port } = await this.ensureService();
|
|
90
|
+
/* create MCP STDIO server (lives for the entire bridge lifetime) */
|
|
94
91
|
const server = new StdioServerTransport();
|
|
95
|
-
/*
|
|
96
|
-
let
|
|
92
|
+
/* track active client and bridge-level closed state */
|
|
93
|
+
let client = null;
|
|
94
|
+
let closedByUs = false; /* set when we initiated the client close */
|
|
95
|
+
let bridgeDone = false; /* set when stdio side closes */
|
|
96
|
+
/* cleanly shut down the whole bridge */
|
|
97
97
|
const shutdown = async () => {
|
|
98
|
-
if (
|
|
98
|
+
if (bridgeDone)
|
|
99
99
|
return;
|
|
100
|
-
|
|
100
|
+
bridgeDone = true;
|
|
101
|
+
closedByUs = true;
|
|
101
102
|
await Promise.allSettled([
|
|
102
103
|
server.close(),
|
|
103
|
-
client
|
|
104
|
+
client?.close()
|
|
104
105
|
]);
|
|
105
106
|
};
|
|
106
|
-
/* connect
|
|
107
|
+
/* (re-)connect the HTTP client to the service */
|
|
108
|
+
const connectClient = async () => {
|
|
109
|
+
const url = new URL(`http://${HOST}:${port}/mcp`);
|
|
110
|
+
const next = new StreamableHTTPClientTransport(url);
|
|
111
|
+
client = next;
|
|
112
|
+
next.onmessage = (msg) => {
|
|
113
|
+
server.send(msg).catch((_err) => {
|
|
114
|
+
const err = _err instanceof Error ? _err : new Error(String(_err));
|
|
115
|
+
this.log.write("error", `mcp: stdout send: ${err.message}`);
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
next.onerror = (err) => {
|
|
119
|
+
this.log.write("error", `mcp: http: ${err.message}`);
|
|
120
|
+
};
|
|
121
|
+
/* service closed the connection — try to recover */
|
|
122
|
+
next.onclose = () => {
|
|
123
|
+
if (closedByUs || bridgeDone)
|
|
124
|
+
return;
|
|
125
|
+
this.log.write("warning", "mcp: http connection lost — reconnecting");
|
|
126
|
+
reconnect().catch(() => { });
|
|
127
|
+
};
|
|
128
|
+
await next.start();
|
|
129
|
+
};
|
|
130
|
+
/* reconnect loop: restart service if needed, then reconnect client */
|
|
131
|
+
const reconnect = async (attempt = 0) => {
|
|
132
|
+
const delay = Math.min(500 * 2 ** attempt, 10000);
|
|
133
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
134
|
+
if (bridgeDone)
|
|
135
|
+
return;
|
|
136
|
+
try {
|
|
137
|
+
const ctx = await this.ensureService();
|
|
138
|
+
port = ctx.port;
|
|
139
|
+
closedByUs = true;
|
|
140
|
+
await client?.close();
|
|
141
|
+
closedByUs = false;
|
|
142
|
+
await connectClient();
|
|
143
|
+
this.log.write("info", "mcp: reconnected to service");
|
|
144
|
+
}
|
|
145
|
+
catch (_err) {
|
|
146
|
+
const err = _err instanceof Error ? _err : new Error(String(_err));
|
|
147
|
+
this.log.write("error", `mcp: reconnect failed: ${err.message}`);
|
|
148
|
+
reconnect(attempt + 1).catch(() => { });
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
/* wire stdio server */
|
|
107
152
|
server.onmessage = (msg) => {
|
|
108
|
-
client
|
|
153
|
+
client?.send(msg).catch((_err) => {
|
|
109
154
|
const err = _err instanceof Error ? _err : new Error(String(_err));
|
|
110
155
|
this.log.write("error", `mcp: http send: ${err.message}`);
|
|
111
156
|
});
|
|
112
157
|
};
|
|
113
|
-
server.onerror = (
|
|
114
|
-
const err = _err instanceof Error ? _err : new Error(String(_err));
|
|
158
|
+
server.onerror = (err) => {
|
|
115
159
|
this.log.write("error", `mcp: stdio: ${err.message}`);
|
|
116
160
|
};
|
|
117
161
|
server.onclose = () => {
|
|
118
162
|
shutdown().catch(() => { });
|
|
119
163
|
};
|
|
120
|
-
/*
|
|
121
|
-
client.onmessage = (msg) => {
|
|
122
|
-
server.send(msg).catch((_err) => {
|
|
123
|
-
const err = _err instanceof Error ? _err : new Error(String(_err));
|
|
124
|
-
this.log.write("error", `mcp: stdout send: ${err.message}`);
|
|
125
|
-
});
|
|
126
|
-
};
|
|
127
|
-
client.onerror = (_err) => {
|
|
128
|
-
const err = _err instanceof Error ? _err : new Error(String(_err));
|
|
129
|
-
this.log.write("error", `mcp: http: ${err.message}`);
|
|
130
|
-
};
|
|
131
|
-
client.onclose = () => {
|
|
132
|
-
shutdown().catch(() => { });
|
|
133
|
-
};
|
|
134
|
-
/* start server and client */
|
|
164
|
+
/* start server and initial client */
|
|
135
165
|
await server.start();
|
|
136
|
-
await
|
|
166
|
+
await connectClient();
|
|
167
|
+
/* periodically probe the service; trigger reconnect if it is gone */
|
|
168
|
+
const HEALTH_INTERVAL_MS = 30_000;
|
|
169
|
+
let reconnecting = false;
|
|
170
|
+
const healthTimer = setInterval(async () => {
|
|
171
|
+
if (bridgeDone || reconnecting)
|
|
172
|
+
return;
|
|
173
|
+
try {
|
|
174
|
+
const { projectId } = this.loadContext();
|
|
175
|
+
const match = await probe(port, projectId);
|
|
176
|
+
if (match !== true) {
|
|
177
|
+
reconnecting = true;
|
|
178
|
+
this.log.write("warning", "mcp: health check failed — reconnecting");
|
|
179
|
+
reconnect().catch(() => { }).finally(() => { reconnecting = false; });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch { /* ignore probe errors */ }
|
|
183
|
+
}, HEALTH_INTERVAL_MS);
|
|
184
|
+
healthTimer.unref();
|
|
137
185
|
/* await stdio to be closed */
|
|
138
186
|
await new Promise((resolve) => {
|
|
139
187
|
const done = () => resolve();
|
|
@@ -141,6 +189,7 @@ export default class MCPCommand {
|
|
|
141
189
|
process.stdin.once("close", done);
|
|
142
190
|
});
|
|
143
191
|
/* shutdown services */
|
|
192
|
+
clearInterval(healthTimer);
|
|
144
193
|
await shutdown();
|
|
145
194
|
return 0;
|
|
146
195
|
}
|
package/dst/ase-service.js
CHANGED
|
@@ -17,7 +17,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
17
17
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
18
18
|
import { z } from "zod";
|
|
19
19
|
import { Config, configSchema, parseScope } from "./ase-config.js";
|
|
20
|
-
import { renderDiagram } from "./ase-diagram.js";
|
|
20
|
+
import { renderDiagram, detectTermWidth, detectTermHeight } from "./ase-diagram.js";
|
|
21
21
|
import { taskLoad, taskSave, taskDelete, taskList } from "./ase-task.js";
|
|
22
22
|
import pkg from "../package.json" with { type: "json" };
|
|
23
23
|
const SERVE_ENV = "ASE_SERVICE_SERVE";
|
|
@@ -231,10 +231,10 @@ export default class ServiceCommand {
|
|
|
231
231
|
.describe("extra horizontal clipping: subtract this many characters from `terminalWidth`"),
|
|
232
232
|
diagramClipY: z.number().int().min(0).default(0)
|
|
233
233
|
.describe("extra vertical clipping: subtract this many lines from `terminalHeight`"),
|
|
234
|
-
terminalWidth: z.number().int().min(0).default(
|
|
235
|
-
.describe("terminal width in characters; 0 disables horizontal clipping"),
|
|
236
|
-
terminalHeight: z.number().int().min(0).default(
|
|
237
|
-
.describe("terminal height in lines; 0 disables vertical clipping")
|
|
234
|
+
terminalWidth: z.number().int().min(0).default(detectTermWidth())
|
|
235
|
+
.describe("terminal width in characters; 0 disables horizontal clipping; defaults to ASE_TERM_WIDTH env var if set"),
|
|
236
|
+
terminalHeight: z.number().int().min(0).default(detectTermHeight())
|
|
237
|
+
.describe("terminal height in lines; 0 disables vertical clipping; defaults to ASE_TERM_HEIGHT env var if set")
|
|
238
238
|
}
|
|
239
239
|
}, async (args) => {
|
|
240
240
|
try {
|
|
@@ -326,7 +326,7 @@ export default class ServiceCommand {
|
|
|
326
326
|
const message = err instanceof Error ? err.message : String(err);
|
|
327
327
|
return {
|
|
328
328
|
isError: true,
|
|
329
|
-
content: [{ type: "text", text: `task_save:
|
|
329
|
+
content: [{ type: "text", text: `task_save: ERROR: ${message}` }]
|
|
330
330
|
};
|
|
331
331
|
}
|
|
332
332
|
});
|
|
@@ -394,7 +394,7 @@ export default class ServiceCommand {
|
|
|
394
394
|
const val = cfg.get("agent.persona");
|
|
395
395
|
if (val === undefined)
|
|
396
396
|
return {
|
|
397
|
-
content: [{ type: "text", text: "" }]
|
|
397
|
+
content: [{ type: "text", text: "engineer" }]
|
|
398
398
|
};
|
|
399
399
|
const text = String(isScalar(val) ? val.value : val);
|
|
400
400
|
return {
|
|
@@ -412,7 +412,7 @@ export default class ServiceCommand {
|
|
|
412
412
|
mcp.registerTool("task_id", {
|
|
413
413
|
title: "ASE task id get/set",
|
|
414
414
|
description: "Get or set the active ASE task `id` for a given `session`. " +
|
|
415
|
-
"If `id` is provided, it
|
|
415
|
+
"If `id` is provided, it sets the task id in the given `session`, " +
|
|
416
416
|
"otherwise it returns the current task `id` of the `session`.",
|
|
417
417
|
inputSchema: {
|
|
418
418
|
id: z.string().optional()
|
|
@@ -467,6 +467,7 @@ export default class ServiceCommand {
|
|
|
467
467
|
method: "GET",
|
|
468
468
|
path: "/stop",
|
|
469
469
|
handler: (_request, h) => {
|
|
470
|
+
this.log.write("info", "service: stop requested");
|
|
470
471
|
setImmediate(async () => {
|
|
471
472
|
await server.stop({ timeout: 1000 });
|
|
472
473
|
process.exit(0);
|
|
@@ -475,6 +476,21 @@ export default class ServiceCommand {
|
|
|
475
476
|
}
|
|
476
477
|
});
|
|
477
478
|
const mcpHandler = async (request, h, body) => {
|
|
479
|
+
const b = body;
|
|
480
|
+
const bParams = b?.params;
|
|
481
|
+
const bMethod = typeof b?.method === "string" ? b.method : null;
|
|
482
|
+
const bName = typeof bParams?.name === "string" ? bParams.name : null;
|
|
483
|
+
const bArgs = bParams?.arguments !== undefined ? bParams.arguments : null;
|
|
484
|
+
let bodyInfo = "";
|
|
485
|
+
if (bMethod !== null) {
|
|
486
|
+
bodyInfo = ` [${bMethod}]`;
|
|
487
|
+
if (bName !== null) {
|
|
488
|
+
bodyInfo += ` ${bName}`;
|
|
489
|
+
if (bArgs !== null)
|
|
490
|
+
bodyInfo += ` ${JSON.stringify(bArgs)}`;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
this.log.write("info", `mcp: ${request.method.toUpperCase()} ${request.path}${bodyInfo}`);
|
|
478
494
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
479
495
|
const mcp = buildMcpServer();
|
|
480
496
|
request.raw.res.on("close", () => {
|
|
@@ -531,6 +547,7 @@ export default class ServiceCommand {
|
|
|
531
547
|
try {
|
|
532
548
|
await server.start();
|
|
533
549
|
persistPort(ctx.svc, ctx.port);
|
|
550
|
+
this.log.write("info", `service: listening on port ${ctx.port}`);
|
|
534
551
|
}
|
|
535
552
|
catch (err) {
|
|
536
553
|
const e = err;
|
|
@@ -552,6 +569,7 @@ export default class ServiceCommand {
|
|
|
552
569
|
return;
|
|
553
570
|
if (Date.now() - lastActivity > IDLE_MS) {
|
|
554
571
|
stopping = true;
|
|
572
|
+
this.log.write("info", "service: idle timeout reached, stopping");
|
|
555
573
|
try {
|
|
556
574
|
await server.stop({ timeout: 1000 });
|
|
557
575
|
clearPort(ctx.svc);
|
package/dst/ase-setup.js
CHANGED
|
@@ -22,26 +22,48 @@ export default class SetupCommand {
|
|
|
22
22
|
}
|
|
23
23
|
/* run a sub-process, suppressing output on success and emitting it on failure */
|
|
24
24
|
async run(cmd, args, opts = {}) {
|
|
25
|
-
const { cwd, quiet = false } = opts;
|
|
25
|
+
const { cwd, quiet = false, retries = 1, ignoreError } = opts;
|
|
26
26
|
this.log.write("info", `setup: $ ${cmd} ${args.join(" ")}` +
|
|
27
27
|
(cwd !== undefined ? ` (cwd: ${cwd})` : ""));
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
28
|
+
for (let i = 0; i < retries; i++) {
|
|
29
|
+
const final = (i === retries - 1);
|
|
30
|
+
try {
|
|
31
|
+
if (quiet) {
|
|
32
|
+
const result = await execa(cmd, args, { stdio: "ignore", cwd, reject: false });
|
|
33
|
+
if (typeof result.exitCode === "number" && result.exitCode !== 0 && !final) {
|
|
34
|
+
this.log.write("info", `setup: attempt ${i + 1}/${retries} failed for "${cmd} ${args.join(" ")}" ` +
|
|
35
|
+
`(exit code: ${result.exitCode}): retrying...`);
|
|
36
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
await execa(cmd, args, { stdio: "pipe", cwd });
|
|
42
|
+
return;
|
|
38
43
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (!final) {
|
|
46
|
+
this.log.write("info", `setup: attempt ${i + 1}/${retries} failed for "${cmd} ${args.join(" ")}": retrying...`);
|
|
47
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (ignoreError !== undefined) {
|
|
51
|
+
this.log.write("info", `setup: ${ignoreError} (skipped)`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const exitCode = typeof err?.exitCode === "number" ? err.exitCode : -1;
|
|
55
|
+
this.log.write("error", `setup: command failed: exit code: ${exitCode}`);
|
|
56
|
+
if (typeof err?.stdout === "string" && err.stdout.length > 0) {
|
|
57
|
+
this.log.write("error", "setup: command failed: stdout:");
|
|
58
|
+
process.stdout.write(err.stdout);
|
|
59
|
+
}
|
|
60
|
+
if (typeof err?.stderr === "string" && err.stderr.length > 0) {
|
|
61
|
+
this.log.write("error", "setup: command failed: stderr:");
|
|
62
|
+
process.stderr.write(err.stderr);
|
|
63
|
+
}
|
|
64
|
+
throw err;
|
|
42
65
|
}
|
|
43
|
-
|
|
44
|
-
});
|
|
66
|
+
}
|
|
45
67
|
}
|
|
46
68
|
/* handler for "ase setup install" */
|
|
47
69
|
async doInstall(dev) {
|
|
@@ -51,7 +73,7 @@ export default class SetupCommand {
|
|
|
51
73
|
`installing ASE Claude Code plugin (origin: ${dev ? "local" : "remote"})`);
|
|
52
74
|
const source = dev ? process.cwd() : "rse/ase";
|
|
53
75
|
await this.run("claude", ["plugin", "marketplace", "add", source]);
|
|
54
|
-
await this.run("claude", ["plugin", "install", "ase@ase"]);
|
|
76
|
+
await this.run("claude", ["plugin", "install", "ase@ase"], { retries: 3 });
|
|
55
77
|
return 0;
|
|
56
78
|
}
|
|
57
79
|
/* handler for "ase setup update" */
|
|
@@ -72,8 +94,8 @@ export default class SetupCommand {
|
|
|
72
94
|
but there is no version change in the plugin manifest,
|
|
73
95
|
so just re-install the plugin to let Claude Code update its copy */
|
|
74
96
|
this.log.write("info", "setup: update[dev]: re-install ASE Claude Code plugin (origin: local)");
|
|
75
|
-
await this.run("claude", ["plugin", "uninstall", "ase@ase"]);
|
|
76
|
-
await this.run("claude", ["plugin", "install", "ase@ase"]);
|
|
97
|
+
await this.run("claude", ["plugin", "uninstall", "ase@ase"], { ignoreError: "ASE Claude Code plugin not installed" });
|
|
98
|
+
await this.run("claude", ["plugin", "install", "ase@ase"], { retries: 3 });
|
|
77
99
|
}
|
|
78
100
|
else {
|
|
79
101
|
/* perform NPM version check */
|
|
@@ -104,8 +126,8 @@ export default class SetupCommand {
|
|
|
104
126
|
/* uninstall ASE Claude Code plugin */
|
|
105
127
|
this.log.write("info", `setup: uninstall${dev ? "[dev]" : ""}: ` +
|
|
106
128
|
`uninstalling ASE Claude Code plugin (origin: ${dev ? "local" : "remote"})`);
|
|
107
|
-
await this.run("claude", ["plugin", "uninstall", "ase@ase"]);
|
|
108
|
-
await this.run("claude", ["plugin", "marketplace", "remove", "ase"]);
|
|
129
|
+
await this.run("claude", ["plugin", "uninstall", "ase@ase"], { ignoreError: "ASE Claude Code plugin not installed" });
|
|
130
|
+
await this.run("claude", ["plugin", "marketplace", "remove", "ase"], { ignoreError: "ASE Claude Code plugin marketplace not registered" });
|
|
109
131
|
/* uninstall ASE CLI tool (non-development only) */
|
|
110
132
|
if (!dev) {
|
|
111
133
|
this.log.write("info", "setup: uninstall: uninstalling ASE CLI tool (origin: remote)");
|
package/dst/ase-statusline.js
CHANGED
|
@@ -4,10 +4,28 @@
|
|
|
4
4
|
** Licensed under GPL 3.0 <https://spdx.org/licenses/GPL-3.0-only>
|
|
5
5
|
*/
|
|
6
6
|
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { InvalidArgumentError } from "commander";
|
|
9
11
|
import { execaSync } from "execa";
|
|
12
|
+
import { Chalk } from "chalk";
|
|
10
13
|
import { Config, configSchema, parseScope } from "./ase-config.js";
|
|
14
|
+
/* forced-color chalk instance: stdout is a pipe under Claude Code,
|
|
15
|
+
so chalk auto-detection would yield level 0; force level 1 to keep
|
|
16
|
+
emitting ANSI sequences as the original implementation did */
|
|
17
|
+
const c = new Chalk({ level: 1 });
|
|
18
|
+
/* set of valid <color>...</color> markup names (chalk basic foreground colors plus "default") */
|
|
19
|
+
const COLORS = new Set([
|
|
20
|
+
"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "default"
|
|
21
|
+
]);
|
|
22
|
+
/* custom argument parser for Commander: non-negative integer */
|
|
23
|
+
const parseInteger = (name) => (value) => {
|
|
24
|
+
const n = Number.parseInt(value, 10);
|
|
25
|
+
if (!Number.isFinite(n) || n < 0)
|
|
26
|
+
throw new InvalidArgumentError(`${name} must be a non-negative integer`);
|
|
27
|
+
return n;
|
|
28
|
+
};
|
|
11
29
|
/* read stdin into a single string */
|
|
12
30
|
const readStdin = async () => {
|
|
13
31
|
const chunks = [];
|
|
@@ -29,6 +47,114 @@ const detectTermWidth = () => {
|
|
|
29
47
|
}
|
|
30
48
|
return width;
|
|
31
49
|
};
|
|
50
|
+
/* format a token count as a compact human-readable string (e.g. 334k, 104.9M) */
|
|
51
|
+
const formatTokens = (n) => {
|
|
52
|
+
if (!Number.isFinite(n) || n < 0)
|
|
53
|
+
return "0";
|
|
54
|
+
if (n >= 1_000_000_000)
|
|
55
|
+
return `${(n / 1_000_000_000).toFixed(1)}G`;
|
|
56
|
+
if (n >= 1_000_000)
|
|
57
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
58
|
+
if (n >= 1_000)
|
|
59
|
+
return `${Math.round(n / 1_000)}k`;
|
|
60
|
+
return `${n}`;
|
|
61
|
+
};
|
|
62
|
+
/* format a millisecond duration as a compact human-readable string (e.g. 6d 12hr 7m, 4hr 27m, 12m 30s) */
|
|
63
|
+
const formatDurationMs = (ms) => {
|
|
64
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
65
|
+
return "0s";
|
|
66
|
+
const totalSec = Math.floor(ms / 1000);
|
|
67
|
+
const days = Math.floor(totalSec / 86400);
|
|
68
|
+
const hours = Math.floor((totalSec % 86400) / 3600);
|
|
69
|
+
const mins = Math.floor((totalSec % 3600) / 60);
|
|
70
|
+
const secs = totalSec % 60;
|
|
71
|
+
if (days > 0)
|
|
72
|
+
return `${days}d ${hours}hr ${mins}m`;
|
|
73
|
+
if (hours > 0)
|
|
74
|
+
return `${hours}hr ${mins}m`;
|
|
75
|
+
if (mins > 0)
|
|
76
|
+
return `${mins}m ${secs}s`;
|
|
77
|
+
return `${secs}s`;
|
|
78
|
+
};
|
|
79
|
+
/* format a wall-clock duration as elapsed hours+minutes (e.g. 92hr 40m), without day rollover */
|
|
80
|
+
const formatHoursMinutes = (ms) => {
|
|
81
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
82
|
+
return "0hr 0m";
|
|
83
|
+
const totalMin = Math.floor(ms / 60000);
|
|
84
|
+
const hours = Math.floor(totalMin / 60);
|
|
85
|
+
const mins = totalMin % 60;
|
|
86
|
+
return `${hours}hr ${mins}m`;
|
|
87
|
+
};
|
|
88
|
+
/* format an ISO timestamp as a remaining-time relative to now (e.g. 4hr 27m, 6d 12hr 7m) */
|
|
89
|
+
const formatTimeUntil = (iso) => {
|
|
90
|
+
const target = Date.parse(iso);
|
|
91
|
+
if (!Number.isFinite(target))
|
|
92
|
+
return "";
|
|
93
|
+
const delta = target - Date.now();
|
|
94
|
+
if (delta <= 0)
|
|
95
|
+
return "0m";
|
|
96
|
+
return formatDurationMs(delta);
|
|
97
|
+
};
|
|
98
|
+
/* format a USD cost as a dollar string with 2 decimals (e.g. $54.44) */
|
|
99
|
+
const formatCostUsd = (n) => {
|
|
100
|
+
if (!Number.isFinite(n) || n < 0)
|
|
101
|
+
return "$0.00";
|
|
102
|
+
return `$${n.toFixed(2)}`;
|
|
103
|
+
};
|
|
104
|
+
/* format a byte count as a compact human-readable string (e.g. 33.2G, 512M) */
|
|
105
|
+
const formatBytes = (n) => {
|
|
106
|
+
if (!Number.isFinite(n) || n < 0)
|
|
107
|
+
return "0";
|
|
108
|
+
if (n >= 1024 ** 3)
|
|
109
|
+
return `${(n / 1024 ** 3).toFixed(1)}G`;
|
|
110
|
+
if (n >= 1024 ** 2)
|
|
111
|
+
return `${(n / 1024 ** 2).toFixed(1)}M`;
|
|
112
|
+
if (n >= 1024)
|
|
113
|
+
return `${(n / 1024).toFixed(1)}k`;
|
|
114
|
+
return `${n}`;
|
|
115
|
+
};
|
|
116
|
+
/* probe local git status for the given working directory */
|
|
117
|
+
const probeGit = (cwd) => {
|
|
118
|
+
try {
|
|
119
|
+
const branch = execFileSync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], { stdio: ["ignore", "pipe", "ignore"], timeout: 1000 })
|
|
120
|
+
.toString("utf8").trim();
|
|
121
|
+
const porc = execFileSync("git", ["-C", cwd, "status", "--porcelain"], { stdio: ["ignore", "pipe", "ignore"], timeout: 1000 })
|
|
122
|
+
.toString("utf8");
|
|
123
|
+
const lines = porc.split("\n").filter((l) => l.length > 0);
|
|
124
|
+
const untracked = lines.filter((l) => l.startsWith("??")).length;
|
|
125
|
+
const dirty = lines.length > 0;
|
|
126
|
+
let added = 0;
|
|
127
|
+
let removed = 0;
|
|
128
|
+
try {
|
|
129
|
+
const shortstat = execFileSync("git", ["-C", cwd, "diff", "--shortstat", "HEAD"], { stdio: ["ignore", "pipe", "ignore"], timeout: 1000 })
|
|
130
|
+
.toString("utf8");
|
|
131
|
+
const mAdd = shortstat.match(/(\d+)\s+insertion/);
|
|
132
|
+
const mDel = shortstat.match(/(\d+)\s+deletion/);
|
|
133
|
+
if (mAdd !== null)
|
|
134
|
+
added = Number.parseInt(mAdd[1], 10);
|
|
135
|
+
if (mDel !== null)
|
|
136
|
+
removed = Number.parseInt(mDel[1], 10);
|
|
137
|
+
}
|
|
138
|
+
catch (_e) {
|
|
139
|
+
/* no HEAD yet or git failure; leave counts at 0 */
|
|
140
|
+
}
|
|
141
|
+
return { branch, dirty, untracked, added, removed };
|
|
142
|
+
}
|
|
143
|
+
catch (_e) {
|
|
144
|
+
return { branch: "", dirty: false, untracked: 0, added: 0, removed: 0 };
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
/* probe local memory usage in bytes (used/total) using OS-portable helpers */
|
|
148
|
+
const probeMemory = () => {
|
|
149
|
+
try {
|
|
150
|
+
const total = os.totalmem();
|
|
151
|
+
const free = os.freemem();
|
|
152
|
+
return { used: total - free, total };
|
|
153
|
+
}
|
|
154
|
+
catch (_e) {
|
|
155
|
+
return { used: 0, total: 0 };
|
|
156
|
+
}
|
|
157
|
+
};
|
|
32
158
|
/* command-line handling */
|
|
33
159
|
export default class StatuslineCommand {
|
|
34
160
|
log;
|
|
@@ -40,7 +166,15 @@ export default class StatuslineCommand {
|
|
|
40
166
|
program
|
|
41
167
|
.command("statusline")
|
|
42
168
|
.description("Render Claude Code statusline from stdin JSON")
|
|
43
|
-
.
|
|
169
|
+
.option("-w, --width <n>", "force terminal width to <n> characters (0 = auto-detect via /dev/tty)", parseInteger("--width"), 0)
|
|
170
|
+
.option("-m, --margin <n>", "reduce maximum used terminal width by <n> characters on each side", parseInteger("--margin"), 2)
|
|
171
|
+
.option("--no-icons", "disable icons in placeholder rendering")
|
|
172
|
+
.option("--no-labels", "disable labels in front of bold values")
|
|
173
|
+
.argument("[lines...]", "one or more template lines with %u %p %T %s %m %e %t %P %c %C %L %N %a %r " +
|
|
174
|
+
"%S %D %W %Q %H %X %b %g %G %d %M %V %o placeholders and <color>...</color> markup " +
|
|
175
|
+
"(color: black, red, green, yellow, blue, magenta, cyan, white, default) " +
|
|
176
|
+
"(default: single line \"%m %e %t\")")
|
|
177
|
+
.action(async (lines, opts) => {
|
|
44
178
|
/* read all of stdin */
|
|
45
179
|
const input = await readStdin();
|
|
46
180
|
/* parse JSON data */
|
|
@@ -53,78 +187,278 @@ export default class StatuslineCommand {
|
|
|
53
187
|
this.log.write("error", `statusline: invalid JSON on stdin: ${message}`);
|
|
54
188
|
process.exit(1);
|
|
55
189
|
}
|
|
56
|
-
/*
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
let
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
190
|
+
/* determine effective terminal width and budget */
|
|
191
|
+
const width = opts.width > 0 ? opts.width : detectTermWidth();
|
|
192
|
+
const budget = width > 0 ? width - 2 * opts.margin : 0;
|
|
193
|
+
/* shared output state and append helper with auto-wrap;
|
|
194
|
+
the helper itself strips ANSI CSI escape sequences to
|
|
195
|
+
measure the raw visible width of the chunk */
|
|
196
|
+
let out = "";
|
|
197
|
+
let col = 0;
|
|
198
|
+
const appendOutput = (ansi) => {
|
|
199
|
+
/* eslint-disable-next-line no-control-regex */
|
|
200
|
+
const raw = ansi.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
|
|
201
|
+
if (budget > 0 && col > 0 && col + raw.length > budget) {
|
|
202
|
+
out += "\n";
|
|
203
|
+
col = 0;
|
|
204
|
+
}
|
|
205
|
+
out += ansi;
|
|
206
|
+
col += raw.length;
|
|
207
|
+
};
|
|
208
|
+
/* active <color> span state: when non-null, renderer/literal output is buffered
|
|
209
|
+
instead of appended directly, and flushed via c[color](buf) on </color> */
|
|
210
|
+
let span = null;
|
|
211
|
+
const emit = (chunk) => {
|
|
212
|
+
if (span !== null)
|
|
213
|
+
span.buf += chunk;
|
|
214
|
+
else
|
|
215
|
+
appendOutput(chunk);
|
|
216
|
+
};
|
|
217
|
+
/* helper to build the "<icon> <label>: " prefix subject to --no-icons / --no-labels */
|
|
218
|
+
const prefix = (icon, label) => {
|
|
219
|
+
const i = opts.icons ? `${icon} ` : "";
|
|
220
|
+
const l = opts.labels ? `${label}: ` : "";
|
|
221
|
+
return `${i}${l}`;
|
|
222
|
+
};
|
|
223
|
+
/* determine effective template lines */
|
|
224
|
+
const tmpl = lines.length > 0 ? lines : ["%m %e %t"];
|
|
225
|
+
/* lazy memoized probes for cross-renderer values: each is computed at most
|
|
226
|
+
once per run and only when first requested by a renderer (or by the
|
|
227
|
+
post-loop tmux publish for the Config cascade) */
|
|
228
|
+
let sessCache = null;
|
|
229
|
+
const getSession = () => {
|
|
230
|
+
if (sessCache === null)
|
|
231
|
+
sessCache = data.session_name ?? data.session_id ?? "unknown";
|
|
232
|
+
return sessCache;
|
|
233
|
+
};
|
|
234
|
+
let cfgCache = null;
|
|
235
|
+
const getCfg = () => {
|
|
236
|
+
if (cfgCache !== null)
|
|
237
|
+
return cfgCache;
|
|
238
|
+
let taskId = process.env.ASE_TASK_ID ?? "";
|
|
239
|
+
let persona = process.env.ASE_PERSONA_STYLE ?? "";
|
|
240
|
+
try {
|
|
241
|
+
const cfg = new Config("config", configSchema, this.log, parseScope(`session:${getSession()}`));
|
|
242
|
+
cfg.read("lenient");
|
|
243
|
+
const t = String(cfg.get("agent.task") ?? "").trim();
|
|
244
|
+
const p = String(cfg.get("agent.persona") ?? "").trim();
|
|
245
|
+
if (t !== "")
|
|
246
|
+
taskId = t;
|
|
247
|
+
if (p !== "")
|
|
248
|
+
persona = p;
|
|
249
|
+
}
|
|
250
|
+
catch (_e) {
|
|
251
|
+
/* cascade unavailable; keep env-var fallbacks */
|
|
252
|
+
}
|
|
253
|
+
cfgCache = { taskId, persona };
|
|
254
|
+
return cfgCache;
|
|
255
|
+
};
|
|
256
|
+
let gitCache = null;
|
|
257
|
+
const getGit = () => {
|
|
258
|
+
if (gitCache === null)
|
|
259
|
+
gitCache = probeGit(data.workspace?.current_dir ?? "");
|
|
260
|
+
return gitCache;
|
|
261
|
+
};
|
|
262
|
+
let memCache = null;
|
|
263
|
+
const getMem = () => {
|
|
264
|
+
if (memCache === null)
|
|
265
|
+
memCache = probeMemory();
|
|
266
|
+
return memCache;
|
|
267
|
+
};
|
|
268
|
+
/* identifier to renderer map: each callback fetches its own information
|
|
269
|
+
directly from data (or via the lazy helpers above for shared values) */
|
|
270
|
+
const renderers = {
|
|
271
|
+
u: () => {
|
|
272
|
+
const user = process.env.USER ?? process.env.LOGNAME ?? "unknown";
|
|
273
|
+
emit(`${prefix("※", "user")}${c.bold(user)}`);
|
|
274
|
+
},
|
|
275
|
+
p: () => {
|
|
276
|
+
const dir = path.basename(data.workspace?.current_dir ?? "");
|
|
277
|
+
emit(`${prefix("⚑", "project")}${c.bold(dir)}`);
|
|
278
|
+
},
|
|
279
|
+
T: () => {
|
|
280
|
+
const { taskId } = getCfg();
|
|
281
|
+
if (taskId !== "")
|
|
282
|
+
emit(`${prefix("◉", "task")}${c.bold(taskId)}`);
|
|
283
|
+
},
|
|
284
|
+
s: () => emit(`${prefix("⏻", "session")}${c.bold(getSession())}`),
|
|
285
|
+
m: () => {
|
|
286
|
+
const model = data.model?.display_name ?? "";
|
|
287
|
+
emit(`${prefix("⚙", "model")}${c.bold(model)}`);
|
|
288
|
+
},
|
|
289
|
+
e: () => {
|
|
290
|
+
const effort = data.effort?.level ?? "unknown";
|
|
291
|
+
emit(`${prefix("⚒", "effort")}${c.bold(effort)}`);
|
|
292
|
+
},
|
|
293
|
+
t: () => {
|
|
294
|
+
const thinking = (data.thinking?.enabled ?? false) === true ? "yes" : "no";
|
|
295
|
+
emit(`${prefix("⚛", "thinking")}${c.bold(thinking)}`);
|
|
296
|
+
},
|
|
297
|
+
P: () => {
|
|
298
|
+
const { persona } = getCfg();
|
|
299
|
+
if (persona !== "")
|
|
300
|
+
emit(`${prefix("☯", "persona")}${c.bold(persona)}`);
|
|
301
|
+
},
|
|
302
|
+
c: () => {
|
|
303
|
+
const pct = Math.floor(data.context_window?.used_percentage ?? 0);
|
|
304
|
+
const barSize = 20;
|
|
305
|
+
const filled = Math.round(pct / 100 * barSize);
|
|
306
|
+
const bar = "█".repeat(filled) + "░".repeat(barSize - filled);
|
|
307
|
+
emit(`${prefix("◔", "context")}${bar} ${pct}%`);
|
|
308
|
+
},
|
|
309
|
+
a: () => {
|
|
310
|
+
const linesAdded = data.cost?.total_lines_added ?? 0;
|
|
311
|
+
emit(`${prefix("⊕", "added")}${c.bold(linesAdded)}`);
|
|
312
|
+
},
|
|
313
|
+
r: () => {
|
|
314
|
+
const linesRemoved = data.cost?.total_lines_removed ?? 0;
|
|
315
|
+
emit(`${prefix("⊖", "removed")}${c.bold(linesRemoved)}`);
|
|
316
|
+
},
|
|
317
|
+
C: () => {
|
|
318
|
+
const ctxIn = data.context_window?.current_usage?.input_tokens ?? 0;
|
|
319
|
+
const ctxCcIn = data.context_window?.current_usage?.cache_creation_input_tokens ?? 0;
|
|
320
|
+
const ctxCrIn = data.context_window?.current_usage?.cache_read_input_tokens ?? 0;
|
|
321
|
+
const tokensCur = ctxIn + ctxCcIn + ctxCrIn;
|
|
322
|
+
if (tokensCur > 0)
|
|
323
|
+
emit(`${prefix("◇", "tokens")}${c.bold(formatTokens(tokensCur))}`);
|
|
324
|
+
},
|
|
325
|
+
L: () => {
|
|
326
|
+
const pct = Math.floor(data.context_window?.used_percentage ?? 0);
|
|
327
|
+
const ctxIn = data.context_window?.current_usage?.input_tokens ?? 0;
|
|
328
|
+
const ctxCcIn = data.context_window?.current_usage?.cache_creation_input_tokens ?? 0;
|
|
329
|
+
const ctxCrIn = data.context_window?.current_usage?.cache_read_input_tokens ?? 0;
|
|
330
|
+
const tokensCur = ctxIn + ctxCcIn + ctxCrIn;
|
|
331
|
+
const tokensLim = pct > 0 && tokensCur > 0 ? Math.round(tokensCur * 100 / pct) : 0;
|
|
332
|
+
if (tokensLim > 0)
|
|
333
|
+
emit(`${prefix("◆", "limit")}${c.bold(formatTokens(tokensLim))}`);
|
|
334
|
+
},
|
|
335
|
+
N: () => {
|
|
336
|
+
const tokensCum = (data.context_window?.total_input_tokens ?? 0) +
|
|
337
|
+
(data.context_window?.total_output_tokens ?? 0);
|
|
338
|
+
if (tokensCum > 0)
|
|
339
|
+
emit(`${prefix("Σ", "total")}${c.bold(formatTokens(tokensCum))}`);
|
|
340
|
+
},
|
|
341
|
+
S: () => {
|
|
342
|
+
const pct5h = data.rate_limits?.five_hour?.used_percentage;
|
|
343
|
+
if (pct5h !== undefined)
|
|
344
|
+
emit(`${prefix("⏲", "session")}${c.bold(`${pct5h.toFixed(1)}%`)}`);
|
|
345
|
+
},
|
|
346
|
+
D: () => {
|
|
347
|
+
const until5h = data.rate_limits?.five_hour?.resets_at ?? "";
|
|
348
|
+
const s = formatTimeUntil(until5h);
|
|
349
|
+
if (s !== "")
|
|
350
|
+
emit(`${prefix("⏱", "session-resets")}${c.bold(s)}`);
|
|
351
|
+
},
|
|
352
|
+
W: () => {
|
|
353
|
+
const pctWk = data.rate_limits?.seven_day?.used_percentage;
|
|
354
|
+
if (pctWk !== undefined)
|
|
355
|
+
emit(`${prefix("⏲", "weekly")}${c.bold(`${pctWk.toFixed(1)}%`)}`);
|
|
356
|
+
},
|
|
357
|
+
Q: () => {
|
|
358
|
+
const untilWk = data.rate_limits?.seven_day?.resets_at ?? "";
|
|
359
|
+
const s = formatTimeUntil(untilWk);
|
|
360
|
+
if (s !== "")
|
|
361
|
+
emit(`${prefix("⏱", "weekly-resets")}${c.bold(s)}`);
|
|
362
|
+
},
|
|
363
|
+
H: () => {
|
|
364
|
+
const sessDurMs = data.cost?.total_duration_ms ?? 0;
|
|
365
|
+
if (sessDurMs > 0)
|
|
366
|
+
emit(`${prefix("⏱", "elapsed")}${c.bold(formatHoursMinutes(sessDurMs))}`);
|
|
367
|
+
},
|
|
368
|
+
X: () => {
|
|
369
|
+
const sessCost = data.cost?.total_cost_usd;
|
|
370
|
+
if (sessCost !== undefined)
|
|
371
|
+
emit(`${prefix("$", "cost")}${c.bold(formatCostUsd(sessCost))}`);
|
|
372
|
+
},
|
|
373
|
+
b: () => {
|
|
374
|
+
const g = getGit();
|
|
375
|
+
const label = g.branch !== "" ? g.branch : "no git";
|
|
376
|
+
emit(`${prefix("⎇", "branch")}${c.bold(label)}`);
|
|
377
|
+
},
|
|
378
|
+
g: () => {
|
|
379
|
+
const g = getGit();
|
|
380
|
+
if (g.branch !== "")
|
|
381
|
+
emit(`${prefix("±", "changed")}${c.bold(`+${g.added}/-${g.removed}`)}`);
|
|
382
|
+
},
|
|
383
|
+
G: () => {
|
|
384
|
+
const g = getGit();
|
|
385
|
+
if (g.branch !== "")
|
|
386
|
+
emit(`${prefix("⁈", "untracked")}${c.bold(String(g.untracked))}`);
|
|
387
|
+
},
|
|
388
|
+
d: () => {
|
|
389
|
+
const cwd = data.workspace?.current_dir ?? "";
|
|
390
|
+
if (cwd !== "")
|
|
391
|
+
emit(`${prefix("▶", "cwd")}${c.bold(cwd)}`);
|
|
392
|
+
},
|
|
393
|
+
M: () => {
|
|
394
|
+
const m = getMem();
|
|
395
|
+
if (m.total > 0)
|
|
396
|
+
emit(`${prefix("⛁", "mem")}${c.bold(`${formatBytes(m.used)}/${formatBytes(m.total)}`)}`);
|
|
397
|
+
},
|
|
398
|
+
V: () => {
|
|
399
|
+
const ccVersion = data.version ?? "";
|
|
400
|
+
if (ccVersion !== "")
|
|
401
|
+
emit(`${prefix("⎈", "version")}${c.bold(ccVersion)}`);
|
|
402
|
+
},
|
|
403
|
+
o: () => {
|
|
404
|
+
const styleName = data.output_style?.name ?? "";
|
|
405
|
+
if (styleName !== "")
|
|
406
|
+
emit(`${prefix("≡", "style")}${c.bold(styleName)}`);
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
/* walk each template line and render */
|
|
410
|
+
for (const line of tmpl) {
|
|
411
|
+
let i = 0;
|
|
412
|
+
while (i < line.length) {
|
|
413
|
+
const ch = line[i];
|
|
414
|
+
const next = line[i + 1];
|
|
415
|
+
if (ch === "<") {
|
|
416
|
+
const m = line.slice(i).match(/^<(\/?)([a-z]+)>/);
|
|
417
|
+
if (m !== null && COLORS.has(m[2])) {
|
|
418
|
+
if (m[1] === "/") {
|
|
419
|
+
if (span !== null) {
|
|
420
|
+
const wrapped = span.color === "default" ?
|
|
421
|
+
span.buf :
|
|
422
|
+
(c[span.color])(span.buf);
|
|
423
|
+
span = null;
|
|
424
|
+
appendOutput(wrapped);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
else if (span === null)
|
|
428
|
+
span = { color: m[2], buf: "" };
|
|
429
|
+
i += m[0].length;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (ch === "%" && next !== undefined && renderers[next] !== undefined) {
|
|
434
|
+
renderers[next]();
|
|
435
|
+
i += 2;
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
emit(ch);
|
|
439
|
+
i += 1;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
/* flush any unterminated span at end of line */
|
|
443
|
+
if (span !== null) {
|
|
444
|
+
const wrapped = span.color === "default" ?
|
|
445
|
+
span.buf :
|
|
446
|
+
(c[span.color])(span.buf);
|
|
447
|
+
span = null;
|
|
448
|
+
appendOutput(wrapped);
|
|
449
|
+
}
|
|
450
|
+
out += "\n";
|
|
451
|
+
col = 0;
|
|
118
452
|
}
|
|
119
|
-
output += `${barColor}◔ context: ${bar} ${pct}%${RESET}\n`;
|
|
120
453
|
/* send output */
|
|
121
|
-
process.stdout.write(
|
|
454
|
+
process.stdout.write(out);
|
|
122
455
|
/* optionally publish task id to the calling tmux pane as a per-pane user
|
|
123
456
|
option, so someone (like claudeX) can pick it up via #{@ase_task_id} */
|
|
124
457
|
if (process.env.TMUX !== undefined
|
|
125
458
|
&& process.env.TMUX !== ""
|
|
126
459
|
&& process.env.TMUX_PANE !== undefined
|
|
127
460
|
&& process.env.TMUX_PANE !== "") {
|
|
461
|
+
const { taskId } = getCfg();
|
|
128
462
|
const tid = taskId !== "" ? taskId : "default";
|
|
129
463
|
execaSync("tmux", ["set-option", "-p", "-t", process.env.TMUX_PANE,
|
|
130
464
|
"@ase_task_id", tid], { stdio: "ignore", reject: false });
|
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"homepage": "http://github.com/rse/ase",
|
|
7
7
|
"repository": { "url": "git+https://github.com/rse/ase.git", "type": "git" },
|
|
8
8
|
"bugs": { "url": "http://github.com/rse/ase/issues" },
|
|
9
|
-
"version": "0.0.
|
|
9
|
+
"version": "0.0.21",
|
|
10
10
|
"license": "GPL-3.0-only",
|
|
11
11
|
"author": {
|
|
12
12
|
"name": "Dr. Ralf S. Engelschall",
|
|
@@ -18,9 +18,8 @@
|
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"eslint": "9.39.4",
|
|
20
20
|
"@eslint/js": "9.39.4",
|
|
21
|
-
"@typescript-eslint/parser": "8.59.
|
|
22
|
-
"@typescript-eslint/eslint-plugin": "8.59.
|
|
23
|
-
"eslint-plugin-n": "17.24.0",
|
|
21
|
+
"@typescript-eslint/parser": "8.59.2",
|
|
22
|
+
"@typescript-eslint/eslint-plugin": "8.59.2",
|
|
24
23
|
"eslint-plugin-promise": "7.3.0",
|
|
25
24
|
"eslint-plugin-import": "2.32.0",
|
|
26
25
|
"neostandard": "0.13.0",
|
|
@@ -38,18 +37,18 @@
|
|
|
38
37
|
"dependencies": {
|
|
39
38
|
"commander": "14.0.3",
|
|
40
39
|
"yaml": "2.8.4",
|
|
41
|
-
"valibot": "1.
|
|
40
|
+
"valibot": "1.4.0",
|
|
42
41
|
"execa": "9.6.1",
|
|
43
42
|
"mkdirp": "3.0.1",
|
|
44
|
-
"@hapi/hapi": "21.4.
|
|
45
|
-
"axios": "1.
|
|
43
|
+
"@hapi/hapi": "21.4.9",
|
|
44
|
+
"axios": "1.16.0",
|
|
46
45
|
"beautiful-mermaid": "1.1.3",
|
|
47
46
|
"cli-table3": "0.6.5",
|
|
48
47
|
"chalk": "5.6.2",
|
|
49
48
|
"pretty-ms": "9.3.0",
|
|
50
49
|
"luxon": "3.7.2",
|
|
51
50
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
52
|
-
"zod": "4.4.
|
|
51
|
+
"zod": "4.4.3",
|
|
53
52
|
"which": "6.0.1"
|
|
54
53
|
},
|
|
55
54
|
"engines": {
|