@rse/ase 0.0.28 → 0.0.30
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-config.js +41 -17
- package/dst/ase-foo.js +21 -0
- package/dst/ase-hook.js +12 -18
- package/dst/ase-kv.js +136 -0
- package/dst/ase-mcp.js +12 -7
- package/dst/ase-persona.js +6 -4
- package/dst/ase-service.js +38 -40
- package/dst/ase-task.js +17 -10
- package/dst/ase-timestamp.js +36 -0
- package/package.json +9 -3
package/dst/ase-config.js
CHANGED
|
@@ -11,6 +11,8 @@ import { Document, parseDocument, isMap, isScalar } from "yaml";
|
|
|
11
11
|
import { execaSync } from "execa";
|
|
12
12
|
import * as v from "valibot";
|
|
13
13
|
import Table from "cli-table3";
|
|
14
|
+
import writeFileAtomic from "write-file-atomic";
|
|
15
|
+
import lockfile from "proper-lockfile";
|
|
14
16
|
/* classification taxonomy */
|
|
15
17
|
export const projectClassification = {
|
|
16
18
|
boxing: ["white", "grey", "black"]
|
|
@@ -207,8 +209,9 @@ export class Config {
|
|
|
207
209
|
findUpward(start, stop, rel) {
|
|
208
210
|
let dir = fs.realpathSync(start);
|
|
209
211
|
const end = fs.realpathSync(stop);
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
+
const steps = dir.split(path.sep).length - end.split(path.sep).length;
|
|
213
|
+
if (steps < 0)
|
|
214
|
+
return null;
|
|
212
215
|
for (let i = 0; i <= steps; i++) {
|
|
213
216
|
const candidate = path.join(dir, rel);
|
|
214
217
|
if (fs.existsSync(candidate))
|
|
@@ -280,6 +283,23 @@ export class Config {
|
|
|
280
283
|
this.validateDoc(docs[i].doc, docs[i].filename, perDocMode);
|
|
281
284
|
}
|
|
282
285
|
}
|
|
286
|
+
/* acquire a cross-process advisory lock on the target scope's file,
|
|
287
|
+
execute the callback, then release the lock */
|
|
288
|
+
lock(cb) {
|
|
289
|
+
const td = this.docs[this.target];
|
|
290
|
+
if (td.scope.kind === "default")
|
|
291
|
+
throw new Error("internal error: \"default\" scope is not lockable");
|
|
292
|
+
fs.mkdirSync(path.dirname(td.filename), { recursive: true });
|
|
293
|
+
if (!fs.existsSync(td.filename))
|
|
294
|
+
fs.writeFileSync(td.filename, "", "utf8");
|
|
295
|
+
const release = lockfile.lockSync(td.filename);
|
|
296
|
+
try {
|
|
297
|
+
cb();
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
release();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
283
303
|
/* write in-memory configuration back to the target scope's file */
|
|
284
304
|
write() {
|
|
285
305
|
const td = this.docs[this.target];
|
|
@@ -287,7 +307,7 @@ export class Config {
|
|
|
287
307
|
throw new Error("internal error: \"default\" scope is not writable");
|
|
288
308
|
this.validateDoc(td.doc, td.filename, "strict");
|
|
289
309
|
fs.mkdirSync(path.dirname(td.filename), { recursive: true });
|
|
290
|
-
|
|
310
|
+
writeFileAtomic.sync(td.filename, td.doc.toString({ indent: 4 }), { encoding: "utf8" });
|
|
291
311
|
}
|
|
292
312
|
/* validate a single YAML document against the optional schema */
|
|
293
313
|
validateDoc(doc, filename, mode = "strict") {
|
|
@@ -314,9 +334,9 @@ export class Config {
|
|
|
314
334
|
progressed = true;
|
|
315
335
|
}
|
|
316
336
|
else
|
|
317
|
-
/* root-level issue
|
|
318
|
-
|
|
319
|
-
|
|
337
|
+
/* root-level issue cannot be deleted; skip it and process
|
|
338
|
+
remaining issues so progressed is tracked correctly */
|
|
339
|
+
continue;
|
|
320
340
|
}
|
|
321
341
|
if (!progressed)
|
|
322
342
|
return;
|
|
@@ -509,14 +529,16 @@ export default class ConfigCommand {
|
|
|
509
529
|
if (preset === undefined)
|
|
510
530
|
throw new Error(`unknown preset "${type}" (expected: default|vibe|pro|industry)`);
|
|
511
531
|
const cfg = new Config("config", configSchema, this.log, scope);
|
|
512
|
-
cfg.
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
532
|
+
cfg.lock(() => {
|
|
533
|
+
cfg.read();
|
|
534
|
+
const targetKind = scope[scope.length - 1].kind;
|
|
535
|
+
for (const [k, val] of Object.entries(preset)) {
|
|
536
|
+
if (!cfg.isWritableOn(k, targetKind))
|
|
537
|
+
continue;
|
|
538
|
+
cfg.set(k, val);
|
|
539
|
+
}
|
|
540
|
+
cfg.write();
|
|
541
|
+
});
|
|
520
542
|
});
|
|
521
543
|
/* register CLI sub-command "ase config list" */
|
|
522
544
|
configCmd
|
|
@@ -594,9 +616,11 @@ export default class ConfigCommand {
|
|
|
594
616
|
.action((key, value, _opts, cmd) => {
|
|
595
617
|
const scope = parseScope(cmd.optsWithGlobals().scope);
|
|
596
618
|
const cfg = new Config("config", configSchema, this.log, scope);
|
|
597
|
-
cfg.
|
|
598
|
-
|
|
599
|
-
|
|
619
|
+
cfg.lock(() => {
|
|
620
|
+
cfg.read();
|
|
621
|
+
cfg.set(key, value);
|
|
622
|
+
cfg.write();
|
|
623
|
+
});
|
|
600
624
|
});
|
|
601
625
|
}
|
|
602
626
|
}
|
package/dst/ase-foo.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
/* command-line handling */
|
|
7
|
+
export default class FooCommand {
|
|
8
|
+
log;
|
|
9
|
+
constructor(log) {
|
|
10
|
+
this.log = log;
|
|
11
|
+
}
|
|
12
|
+
/* register commands */
|
|
13
|
+
register(program) {
|
|
14
|
+
program
|
|
15
|
+
.command("foo")
|
|
16
|
+
.description("Print a nice Hello World message")
|
|
17
|
+
.action(() => {
|
|
18
|
+
process.stdout.write("Hello, World!\n");
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
package/dst/ase-hook.js
CHANGED
|
@@ -7,6 +7,7 @@ import path from "node:path";
|
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import os from "node:os";
|
|
9
9
|
import { execaSync } from "execa";
|
|
10
|
+
import { quote } from "shell-quote";
|
|
10
11
|
import Version from "./ase-version.js";
|
|
11
12
|
import { Config, configSchema, parseScope } from "./ase-config.js";
|
|
12
13
|
const toolSpecs = {
|
|
@@ -95,23 +96,16 @@ export default class HookCommand {
|
|
|
95
96
|
/* establish config context (session-scoped only if a valid sessionId is present) */
|
|
96
97
|
const hasSession = /^[A-Za-z0-9._-]+$/.test(sessionId);
|
|
97
98
|
const cfg = new Config("config", configSchema, this.log, hasSession ? parseScope(`session:${sessionId}`) : parseScope(undefined));
|
|
98
|
-
|
|
99
|
+
cfg.lock(() => {
|
|
99
100
|
cfg.read();
|
|
100
|
-
}
|
|
101
|
-
catch (_e) {
|
|
102
|
-
/* best-effort: ignore failures */
|
|
103
|
-
}
|
|
101
|
+
});
|
|
104
102
|
/* determine task id (only persist when scoped to a real session) */
|
|
105
103
|
const taskId = process.env.ASE_TASK_ID ?? "default";
|
|
106
|
-
if (hasSession)
|
|
107
|
-
|
|
104
|
+
if (hasSession)
|
|
105
|
+
cfg.lock(() => {
|
|
108
106
|
cfg.set("agent.task", taskId);
|
|
109
107
|
cfg.write();
|
|
110
|
-
}
|
|
111
|
-
catch (_e) {
|
|
112
|
-
/* best-effort: ignore failures */
|
|
113
|
-
}
|
|
114
|
-
}
|
|
108
|
+
});
|
|
115
109
|
/* determine project id */
|
|
116
110
|
const cwd = input.cwd ?? process.cwd();
|
|
117
111
|
let projectDir = cwd;
|
|
@@ -139,12 +133,12 @@ export default class HookCommand {
|
|
|
139
133
|
(Claude Code only -- Copilot CLI has no equivalent mechanism) */
|
|
140
134
|
const envFile = tool === "claude" ? (process.env.CLAUDE_ENV_FILE ?? "") : "";
|
|
141
135
|
if (envFile !== "") {
|
|
142
|
-
const script = `export ASE_VERSION
|
|
143
|
-
`export ASE_USER_ID
|
|
144
|
-
`export ASE_PROJECT_ID
|
|
145
|
-
`export ASE_TASK_ID
|
|
146
|
-
`export ASE_SESSION_ID
|
|
147
|
-
`export ASE_HEADLESS
|
|
136
|
+
const script = `export ASE_VERSION=${quote([versionCurrentPlugin])}\n` +
|
|
137
|
+
`export ASE_USER_ID=${quote([userId])}\n` +
|
|
138
|
+
`export ASE_PROJECT_ID=${quote([projectId])}\n` +
|
|
139
|
+
`export ASE_TASK_ID=${quote([taskId])}\n` +
|
|
140
|
+
`export ASE_SESSION_ID=${quote([sessionId])}\n` +
|
|
141
|
+
`export ASE_HEADLESS=${quote([headless])}\n`;
|
|
148
142
|
fs.appendFileSync(envFile, script, "utf8");
|
|
149
143
|
}
|
|
150
144
|
/* prepend ASE information to constitution markdown */
|
package/dst/ase-kv.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
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 { z } from "zod";
|
|
7
|
+
/* reusable functionality: in-memory key/value store living inside the
|
|
8
|
+
"ase service" process; per-project (one service per project) and
|
|
9
|
+
not persisted; intended for sharing information between skills
|
|
10
|
+
across multiple Claude Code instances connected to the same service */
|
|
11
|
+
export class KV {
|
|
12
|
+
/* the actual in-memory store */
|
|
13
|
+
static store = new Map();
|
|
14
|
+
/* maximum allowed key length, to keep memory bounded */
|
|
15
|
+
static KEY_MAX_LEN = 1024;
|
|
16
|
+
/* validate the key to keep it non-empty and bounded in length */
|
|
17
|
+
static validateKey(key) {
|
|
18
|
+
if (typeof key !== "string" || key.length === 0)
|
|
19
|
+
throw new Error("kv: key must be a non-empty string");
|
|
20
|
+
if (key.length > KV.KEY_MAX_LEN)
|
|
21
|
+
throw new Error(`kv: key must be no longer than ${KV.KEY_MAX_LEN} characters`);
|
|
22
|
+
}
|
|
23
|
+
/* test whether a value is stored under the given key */
|
|
24
|
+
static has(key) {
|
|
25
|
+
KV.validateKey(key);
|
|
26
|
+
return KV.store.has(key);
|
|
27
|
+
}
|
|
28
|
+
/* get a value by key; returns undefined if no value is stored */
|
|
29
|
+
static get(key) {
|
|
30
|
+
KV.validateKey(key);
|
|
31
|
+
return KV.store.get(key);
|
|
32
|
+
}
|
|
33
|
+
/* set a value under the given key; overwrites any existing value */
|
|
34
|
+
static set(key, val) {
|
|
35
|
+
KV.validateKey(key);
|
|
36
|
+
KV.store.set(key, val);
|
|
37
|
+
}
|
|
38
|
+
/* delete a value by key; returns true if a value existed */
|
|
39
|
+
static delete(key) {
|
|
40
|
+
KV.validateKey(key);
|
|
41
|
+
return KV.store.delete(key);
|
|
42
|
+
}
|
|
43
|
+
/* clear all keys; returns the number of keys removed */
|
|
44
|
+
static clear() {
|
|
45
|
+
const n = KV.store.size;
|
|
46
|
+
KV.store.clear();
|
|
47
|
+
return n;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/* MCP registration entry point for in-memory key/value tools */
|
|
51
|
+
export class KVMCP {
|
|
52
|
+
register(mcp) {
|
|
53
|
+
/* key/value get */
|
|
54
|
+
mcp.registerTool("kv_get", {
|
|
55
|
+
title: "ASE key/value get",
|
|
56
|
+
description: "Get a value from the in-memory key/value store by `key`. " +
|
|
57
|
+
"Returns the value as JSON-encoded `text`; returns an empty string if no value is stored.",
|
|
58
|
+
inputSchema: {
|
|
59
|
+
key: z.string()
|
|
60
|
+
.describe("key identifier (non-empty string, up to 1024 characters)")
|
|
61
|
+
}
|
|
62
|
+
}, async (args) => {
|
|
63
|
+
try {
|
|
64
|
+
if (!KV.has(args.key))
|
|
65
|
+
return { content: [{ type: "text", text: "" }] };
|
|
66
|
+
const val = KV.get(args.key);
|
|
67
|
+
const text = JSON.stringify(val);
|
|
68
|
+
return { content: [{ type: "text", text }] };
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
72
|
+
return { isError: true, content: [{ type: "text", text: `kv_get: ERROR: ${message}` }] };
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
/* key/value set */
|
|
76
|
+
mcp.registerTool("kv_set", {
|
|
77
|
+
title: "ASE key/value set",
|
|
78
|
+
description: "Store a `val` under the given `key` in the in-memory key/value store. " +
|
|
79
|
+
"Overwrites any existing value for the same `key`. " +
|
|
80
|
+
"The value can be any JSON-compatible type (string, number, boolean, null, array, object).",
|
|
81
|
+
inputSchema: {
|
|
82
|
+
key: z.string()
|
|
83
|
+
.describe("key identifier (non-empty string, up to 1024 characters)"),
|
|
84
|
+
val: z.any()
|
|
85
|
+
.describe("arbitrary JSON-compatible value to store under `key`")
|
|
86
|
+
}
|
|
87
|
+
}, async (args) => {
|
|
88
|
+
try {
|
|
89
|
+
KV.set(args.key, args.val);
|
|
90
|
+
return { content: [{ type: "text", text: `kv_set: OK: stored key "${args.key}"` }] };
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
94
|
+
return { isError: true, content: [{ type: "text", text: `kv_set: ERROR: ${message}` }] };
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
/* key/value clear */
|
|
98
|
+
mcp.registerTool("kv_clear", {
|
|
99
|
+
title: "ASE key/value clear",
|
|
100
|
+
description: "Remove all keys from the in-memory key/value store. " +
|
|
101
|
+
"Returns a status `text` indicating how many keys were removed.",
|
|
102
|
+
inputSchema: {}
|
|
103
|
+
}, async () => {
|
|
104
|
+
try {
|
|
105
|
+
const n = KV.clear();
|
|
106
|
+
return { content: [{ type: "text", text: `kv_clear: OK: removed ${n} key(s)` }] };
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
110
|
+
return { isError: true, content: [{ type: "text", text: `kv_clear: ERROR: ${message}` }] };
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
/* key/value delete */
|
|
114
|
+
mcp.registerTool("kv_delete", {
|
|
115
|
+
title: "ASE key/value delete",
|
|
116
|
+
description: "Delete a value from the in-memory key/value store by `key`. " +
|
|
117
|
+
"Returns a status `text` indicating whether a value existed and was removed.",
|
|
118
|
+
inputSchema: {
|
|
119
|
+
key: z.string()
|
|
120
|
+
.describe("key identifier (non-empty string, up to 1024 characters)")
|
|
121
|
+
}
|
|
122
|
+
}, async (args) => {
|
|
123
|
+
try {
|
|
124
|
+
const removed = KV.delete(args.key);
|
|
125
|
+
const msg = removed ?
|
|
126
|
+
`kv_delete: OK: removed key "${args.key}"` :
|
|
127
|
+
`kv_delete: WARNING: no key "${args.key}" to remove`;
|
|
128
|
+
return { content: [{ type: "text", text: msg }] };
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
132
|
+
return { isError: true, content: [{ type: "text", text: `kv_delete: ERROR: ${message}` }] };
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
package/dst/ase-mcp.js
CHANGED
|
@@ -68,10 +68,12 @@ export default class MCPCommand {
|
|
|
68
68
|
return;
|
|
69
69
|
bridgeDone = true;
|
|
70
70
|
closedByUs = true;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
client?.close()
|
|
71
|
+
const timeout = new Promise((resolve) => setTimeout(resolve, 3000));
|
|
72
|
+
await Promise.race([
|
|
73
|
+
Promise.allSettled([server.close(), client?.close()]),
|
|
74
|
+
timeout
|
|
74
75
|
]);
|
|
76
|
+
process.exit(0);
|
|
75
77
|
};
|
|
76
78
|
/* (re-)connect the HTTP client to the service */
|
|
77
79
|
const connectClient = async () => {
|
|
@@ -97,11 +99,13 @@ export default class MCPCommand {
|
|
|
97
99
|
await next.start();
|
|
98
100
|
};
|
|
99
101
|
/* reconnect loop: restart service if needed, then reconnect client */
|
|
100
|
-
const reconnect = async (attempt = 0) => {
|
|
102
|
+
const reconnect = async (attempt = 0, done) => {
|
|
101
103
|
const delay = Math.min(500 * 2 ** attempt, 10000);
|
|
102
104
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
103
|
-
if (bridgeDone)
|
|
105
|
+
if (bridgeDone) {
|
|
106
|
+
done?.();
|
|
104
107
|
return;
|
|
108
|
+
}
|
|
105
109
|
try {
|
|
106
110
|
const ctx = await this.ensureService();
|
|
107
111
|
port = ctx.port;
|
|
@@ -110,11 +114,12 @@ export default class MCPCommand {
|
|
|
110
114
|
closedByUs = false;
|
|
111
115
|
await connectClient();
|
|
112
116
|
this.log.write("info", "mcp: reconnected to service");
|
|
117
|
+
done?.();
|
|
113
118
|
}
|
|
114
119
|
catch (_err) {
|
|
115
120
|
const err = _err instanceof Error ? _err : new Error(String(_err));
|
|
116
121
|
this.log.write("error", `mcp: reconnect failed: ${err.message}`);
|
|
117
|
-
reconnect(attempt + 1).catch(() => { });
|
|
122
|
+
reconnect(attempt + 1, done).catch(() => { });
|
|
118
123
|
}
|
|
119
124
|
};
|
|
120
125
|
/* wire stdio server */
|
|
@@ -145,7 +150,7 @@ export default class MCPCommand {
|
|
|
145
150
|
if (match !== true) {
|
|
146
151
|
reconnecting = true;
|
|
147
152
|
this.log.write("warning", "mcp: health check failed — reconnecting");
|
|
148
|
-
reconnect(
|
|
153
|
+
reconnect(0, () => { reconnecting = false; }).catch(() => { });
|
|
149
154
|
}
|
|
150
155
|
}
|
|
151
156
|
catch { /* ignore probe errors */ }
|
package/dst/ase-persona.js
CHANGED
|
@@ -29,9 +29,11 @@ export class Persona {
|
|
|
29
29
|
parseScope(`session:${session}`) :
|
|
30
30
|
parseScope(undefined);
|
|
31
31
|
const cfg = new Config("config", configSchema, log, scope);
|
|
32
|
-
cfg.
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
cfg.lock(() => {
|
|
33
|
+
cfg.read();
|
|
34
|
+
cfg.set("agent.persona", style);
|
|
35
|
+
cfg.write();
|
|
36
|
+
});
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
39
|
/* MCP registration entry point for persona tools */
|
|
@@ -47,7 +49,7 @@ export default class PersonaMCP {
|
|
|
47
49
|
"If `style` is provided, it sets the persona style, " +
|
|
48
50
|
"otherwise it returns the current persona `style`. " +
|
|
49
51
|
"If `session` is provided, the operation is scoped to that session, " +
|
|
50
|
-
"otherwise it operates on the
|
|
52
|
+
"otherwise it operates on the strongest/closest scope (user/project cascade). " +
|
|
51
53
|
"Allowed styles: \"writer\" (decorative, eloquent, explaining), " +
|
|
52
54
|
"\"engineer\" (brief, factual, accurate), " +
|
|
53
55
|
"\"telegrapher\" (very brief, factual, abbreviating), " +
|
package/dst/ase-service.js
CHANGED
|
@@ -15,12 +15,12 @@ import prettyMs from "pretty-ms";
|
|
|
15
15
|
import * as v from "valibot";
|
|
16
16
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
17
17
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
18
|
-
import { z } from "zod";
|
|
19
|
-
import { DateTime } from "luxon";
|
|
20
18
|
import { Config, configSchema } from "./ase-config.js";
|
|
21
19
|
import { DiagramMCP } from "./ase-diagram.js";
|
|
22
20
|
import { TaskMCP } from "./ase-task.js";
|
|
21
|
+
import { KVMCP } from "./ase-kv.js";
|
|
23
22
|
import PersonaMCP from "./ase-persona.js";
|
|
23
|
+
import { TimestampMCP } from "./ase-timestamp.js";
|
|
24
24
|
import pkg from "../package.json" with { type: "json" };
|
|
25
25
|
/* shared service host */
|
|
26
26
|
export const SERVICE_HOST = "127.0.0.1";
|
|
@@ -87,20 +87,26 @@ export class Service {
|
|
|
87
87
|
}
|
|
88
88
|
/* persist an allocated port into ".ase/service.yaml" */
|
|
89
89
|
static persistPort(svc, port) {
|
|
90
|
-
svc.
|
|
91
|
-
|
|
90
|
+
svc.lock(() => {
|
|
91
|
+
svc.read();
|
|
92
|
+
svc.set("port", port);
|
|
93
|
+
svc.write();
|
|
94
|
+
});
|
|
92
95
|
}
|
|
93
96
|
/* clear the persisted port and remove ".ase/service.yaml" if it is empty */
|
|
94
97
|
static clearPort(svc) {
|
|
95
|
-
svc.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
98
|
+
svc.lock(() => {
|
|
99
|
+
svc.read();
|
|
100
|
+
svc.delete("port");
|
|
101
|
+
const root = svc.get();
|
|
102
|
+
const empty = root === undefined || root === null || (isMap(root) && root.items.length === 0);
|
|
103
|
+
if (empty) {
|
|
104
|
+
if (fs.existsSync(svc.filename))
|
|
105
|
+
fs.rmSync(svc.filename);
|
|
106
|
+
}
|
|
107
|
+
else
|
|
108
|
+
svc.write();
|
|
109
|
+
});
|
|
104
110
|
}
|
|
105
111
|
/* spawn the current executable detached as a background service */
|
|
106
112
|
static spawnDetached(aseDir, port) {
|
|
@@ -171,30 +177,6 @@ export class ServiceMCP {
|
|
|
171
177
|
content: [{ type: "text", text: JSON.stringify(status) }]
|
|
172
178
|
};
|
|
173
179
|
});
|
|
174
|
-
mcp.registerTool("timestamp", {
|
|
175
|
-
title: "ASE timestamp",
|
|
176
|
-
description: "Return the current local date/time formatted via a Luxon format string. " +
|
|
177
|
-
"Pass the Luxon format tokens as `format` (default: `yyyy-LL-dd HH:mm`). " +
|
|
178
|
-
"Returns the formatted timestamp as `text`.",
|
|
179
|
-
inputSchema: {
|
|
180
|
-
format: z.string().default("yyyy-LL-dd HH:mm")
|
|
181
|
-
.describe("Luxon format tokens (default: `yyyy-LL-dd HH:mm`)")
|
|
182
|
-
}
|
|
183
|
-
}, async (args) => {
|
|
184
|
-
try {
|
|
185
|
-
const text = DateTime.now().toFormat(args.format);
|
|
186
|
-
return {
|
|
187
|
-
content: [{ type: "text", text }]
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
|
-
catch (err) {
|
|
191
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
192
|
-
return {
|
|
193
|
-
isError: true,
|
|
194
|
-
content: [{ type: "text", text: `timestamp: format failed: ${message}` }]
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
});
|
|
198
180
|
}
|
|
199
181
|
}
|
|
200
182
|
/* CLI command "ase service" */
|
|
@@ -233,8 +215,15 @@ export default class ServiceCommand {
|
|
|
233
215
|
/* track start time and last activity */
|
|
234
216
|
const startTime = Date.now();
|
|
235
217
|
let lastActivity = Date.now();
|
|
218
|
+
let inFlight = 0;
|
|
236
219
|
let stopping = false;
|
|
237
220
|
server.ext("onRequest", (_request, h) => {
|
|
221
|
+
inFlight++;
|
|
222
|
+
lastActivity = Date.now();
|
|
223
|
+
return h.continue;
|
|
224
|
+
});
|
|
225
|
+
server.ext("onPreResponse", (_request, h) => {
|
|
226
|
+
inFlight = Math.max(0, inFlight - 1);
|
|
238
227
|
lastActivity = Date.now();
|
|
239
228
|
return h.continue;
|
|
240
229
|
});
|
|
@@ -244,7 +233,9 @@ export default class ServiceCommand {
|
|
|
244
233
|
new ServiceMCP({ projectId: ctx.projectId, port: ctx.port, startTime }).register(mcp);
|
|
245
234
|
new DiagramMCP().register(mcp);
|
|
246
235
|
new TaskMCP(this.log).register(mcp);
|
|
236
|
+
new KVMCP().register(mcp);
|
|
247
237
|
new PersonaMCP(this.log).register(mcp);
|
|
238
|
+
new TimestampMCP().register(mcp);
|
|
248
239
|
return mcp;
|
|
249
240
|
};
|
|
250
241
|
/* listen to HTTP/REST endpoints */
|
|
@@ -359,6 +350,8 @@ export default class ServiceCommand {
|
|
|
359
350
|
setInterval(async () => {
|
|
360
351
|
if (stopping)
|
|
361
352
|
return;
|
|
353
|
+
if (inFlight > 0)
|
|
354
|
+
return;
|
|
362
355
|
if (Date.now() - lastActivity > IDLE_MS) {
|
|
363
356
|
stopping = true;
|
|
364
357
|
this.log.write("info", "service: idle timeout reached, stopping");
|
|
@@ -394,7 +387,7 @@ export default class ServiceCommand {
|
|
|
394
387
|
}
|
|
395
388
|
}
|
|
396
389
|
/* bounded retry across the bind/start TOCTOU window: on each attempt
|
|
397
|
-
re-allocate, re-
|
|
390
|
+
re-allocate, re-spawn; early-break on foreign listener */
|
|
398
391
|
let lastErr = new Error("service failed to start within timeout");
|
|
399
392
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
400
393
|
port = await Service.allocatePort();
|
|
@@ -449,12 +442,17 @@ export default class ServiceCommand {
|
|
|
449
442
|
]);
|
|
450
443
|
if (!exited) {
|
|
451
444
|
child.kill("SIGKILL");
|
|
452
|
-
await
|
|
445
|
+
await Promise.race([
|
|
446
|
+
exitPromise,
|
|
447
|
+
new Promise((resolve) => setTimeout(resolve, 2000))
|
|
448
|
+
]);
|
|
449
|
+
child.unref();
|
|
453
450
|
}
|
|
454
451
|
}
|
|
452
|
+
if (!success)
|
|
453
|
+
Service.clearPort(ctx.svc);
|
|
455
454
|
}
|
|
456
455
|
}
|
|
457
|
-
Service.clearPort(ctx.svc);
|
|
458
456
|
throw lastErr;
|
|
459
457
|
}
|
|
460
458
|
/* status flow: report whether the service is running */
|
package/dst/ase-task.js
CHANGED
|
@@ -131,9 +131,11 @@ export class Task {
|
|
|
131
131
|
static setId(log, session, id) {
|
|
132
132
|
const scope = parseScope(`session:${session}`);
|
|
133
133
|
const cfg = new Config("config", configSchema, log, scope);
|
|
134
|
-
cfg.
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
cfg.lock(() => {
|
|
135
|
+
cfg.read();
|
|
136
|
+
cfg.set("agent.task", id);
|
|
137
|
+
cfg.write();
|
|
138
|
+
});
|
|
137
139
|
}
|
|
138
140
|
}
|
|
139
141
|
/* read all of stdin as a UTF-8 string */
|
|
@@ -261,7 +263,9 @@ export class TaskMCP {
|
|
|
261
263
|
constructor(log) {
|
|
262
264
|
this.log = log;
|
|
263
265
|
}
|
|
266
|
+
/* register MCP tools */
|
|
264
267
|
register(mcp) {
|
|
268
|
+
/* task list */
|
|
265
269
|
mcp.registerTool("task_list", {
|
|
266
270
|
title: "ASE task list",
|
|
267
271
|
description: "List all persisted tasks. " +
|
|
@@ -297,10 +301,11 @@ export class TaskMCP {
|
|
|
297
301
|
const message = err instanceof Error ? err.message : String(err);
|
|
298
302
|
return {
|
|
299
303
|
isError: true,
|
|
300
|
-
content: [{ type: "text", text: `
|
|
304
|
+
content: [{ type: "text", text: `ERROR: ${message}` }]
|
|
301
305
|
};
|
|
302
306
|
}
|
|
303
307
|
});
|
|
308
|
+
/* task load */
|
|
304
309
|
mcp.registerTool("task_load", {
|
|
305
310
|
title: "ASE task load",
|
|
306
311
|
description: "Load a previously persisted task by `id`. " +
|
|
@@ -320,10 +325,11 @@ export class TaskMCP {
|
|
|
320
325
|
const message = err instanceof Error ? err.message : String(err);
|
|
321
326
|
return {
|
|
322
327
|
isError: true,
|
|
323
|
-
content: [{ type: "text", text: `
|
|
328
|
+
content: [{ type: "text", text: `ERROR: ${message}` }]
|
|
324
329
|
};
|
|
325
330
|
}
|
|
326
331
|
});
|
|
332
|
+
/* task save */
|
|
327
333
|
mcp.registerTool("task_save", {
|
|
328
334
|
title: "ASE task save",
|
|
329
335
|
description: "Persist a task as `text` under `id`. " +
|
|
@@ -345,7 +351,7 @@ export class TaskMCP {
|
|
|
345
351
|
const message = err instanceof Error ? err.message : String(err);
|
|
346
352
|
return {
|
|
347
353
|
isError: true,
|
|
348
|
-
content: [{ type: "text", text: `
|
|
354
|
+
content: [{ type: "text", text: `ERROR: ${message}` }]
|
|
349
355
|
};
|
|
350
356
|
}
|
|
351
357
|
});
|
|
@@ -361,8 +367,8 @@ export class TaskMCP {
|
|
|
361
367
|
try {
|
|
362
368
|
const removed = Task.delete(args.id);
|
|
363
369
|
const msg = removed ?
|
|
364
|
-
|
|
365
|
-
|
|
370
|
+
"OK: removed task" :
|
|
371
|
+
"WARNING: task not found";
|
|
366
372
|
return {
|
|
367
373
|
content: [{ type: "text", text: msg }]
|
|
368
374
|
};
|
|
@@ -371,10 +377,11 @@ export class TaskMCP {
|
|
|
371
377
|
const message = err instanceof Error ? err.message : String(err);
|
|
372
378
|
return {
|
|
373
379
|
isError: true,
|
|
374
|
-
content: [{ type: "text", text: `
|
|
380
|
+
content: [{ type: "text", text: `ERROR: ${message}` }]
|
|
375
381
|
};
|
|
376
382
|
}
|
|
377
383
|
});
|
|
384
|
+
/* task id get/set */
|
|
378
385
|
mcp.registerTool("task_id", {
|
|
379
386
|
title: "ASE task id get/set",
|
|
380
387
|
description: "Get or set the active ASE task `id` for a given `session`. " +
|
|
@@ -406,7 +413,7 @@ export class TaskMCP {
|
|
|
406
413
|
const message = err instanceof Error ? err.message : String(err);
|
|
407
414
|
return {
|
|
408
415
|
isError: true,
|
|
409
|
-
content: [{ type: "text", text: `
|
|
416
|
+
content: [{ type: "text", text: `ERROR: ${message}` }]
|
|
410
417
|
};
|
|
411
418
|
}
|
|
412
419
|
});
|
|
@@ -0,0 +1,36 @@
|
|
|
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 { z } from "zod";
|
|
7
|
+
import { DateTime } from "luxon";
|
|
8
|
+
/* MCP registration entry point for timestamp tool */
|
|
9
|
+
export class TimestampMCP {
|
|
10
|
+
register(mcp) {
|
|
11
|
+
mcp.registerTool("timestamp", {
|
|
12
|
+
title: "ASE timestamp",
|
|
13
|
+
description: "Return the current local date/time formatted via a Luxon format string. " +
|
|
14
|
+
"Pass the Luxon format tokens as `format` (default: `yyyy-LL-dd HH:mm`). " +
|
|
15
|
+
"Returns the formatted timestamp as `text`.",
|
|
16
|
+
inputSchema: {
|
|
17
|
+
format: z.string().default("yyyy-LL-dd HH:mm")
|
|
18
|
+
.describe("Luxon format tokens (default: `yyyy-LL-dd HH:mm`)")
|
|
19
|
+
}
|
|
20
|
+
}, async (args) => {
|
|
21
|
+
try {
|
|
22
|
+
const text = DateTime.now().toFormat(args.format);
|
|
23
|
+
return {
|
|
24
|
+
content: [{ type: "text", text }]
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
29
|
+
return {
|
|
30
|
+
isError: true,
|
|
31
|
+
content: [{ type: "text", text: `timestamp: format failed: ${message}` }]
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
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.30",
|
|
10
10
|
"license": "GPL-3.0-only",
|
|
11
11
|
"author": {
|
|
12
12
|
"name": "Dr. Ralf S. Engelschall",
|
|
@@ -33,7 +33,10 @@
|
|
|
33
33
|
"@types/node": "25.8.0",
|
|
34
34
|
"@types/luxon": "3.7.1",
|
|
35
35
|
"@types/which": "3.0.4",
|
|
36
|
-
"@types/update-notifier": "6.0.8"
|
|
36
|
+
"@types/update-notifier": "6.0.8",
|
|
37
|
+
"@types/shell-quote": "1.7.5",
|
|
38
|
+
"@types/proper-lockfile": "4.1.4",
|
|
39
|
+
"@types/write-file-atomic": "4.0.3"
|
|
37
40
|
},
|
|
38
41
|
"dependencies": {
|
|
39
42
|
"commander": "14.0.3",
|
|
@@ -51,7 +54,10 @@
|
|
|
51
54
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
52
55
|
"zod": "4.4.3",
|
|
53
56
|
"which": "7.0.0",
|
|
54
|
-
"update-notifier": "7.3.1"
|
|
57
|
+
"update-notifier": "7.3.1",
|
|
58
|
+
"shell-quote": "1.8.3",
|
|
59
|
+
"proper-lockfile": "4.1.2",
|
|
60
|
+
"write-file-atomic": "4.0.0"
|
|
55
61
|
},
|
|
56
62
|
"engines": {
|
|
57
63
|
"npm": ">=10.0.0",
|