@jitsusama/agentic-harness.core 0.6.0 → 0.6.2
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/dist/attribution/commit-hook.d.ts +8 -5
- package/dist/attribution/commit-hook.js +57 -32
- package/dist/internal/quest/process-liveness.d.ts +5 -0
- package/dist/internal/quest/process-liveness.js +12 -0
- package/dist/lsp/standalone/backend.d.ts +8 -0
- package/dist/lsp/standalone/backend.js +92 -23
- package/package.json +1 -1
|
@@ -42,11 +42,14 @@ export interface HookInstall {
|
|
|
42
42
|
export declare function installCommitHook(repoRoot: string, options: CommitHookOptions): HookInstall;
|
|
43
43
|
/**
|
|
44
44
|
* Ensure the hook is installed in the repo containing dir, at most
|
|
45
|
-
* once per repo
|
|
46
|
-
* so later
|
|
47
|
-
* best-effort. A directory outside any git repo is a no-op
|
|
48
|
-
*
|
|
49
|
-
*
|
|
45
|
+
* once per repo. Records both dir and its repo root in `installed`,
|
|
46
|
+
* so a later command from either asks git nothing, and installs
|
|
47
|
+
* best-effort. A directory outside any git repo is a no-op and is
|
|
48
|
+
* not remembered, so a repo initialised there later is still
|
|
49
|
+
* covered. This is how hook coverage follows the session into repos
|
|
50
|
+
* it later cds into, rather than only the repo the session started
|
|
51
|
+
* in. Each git call is a synchronous spawn on the command path, so
|
|
52
|
+
* a first visit costs one and a repeat costs none.
|
|
50
53
|
*/
|
|
51
54
|
export declare function ensureCommitHook(dir: string, installed: Set<string>, options: CommitHookOptions): void;
|
|
52
55
|
/** The git repository root containing dir, or null when there is none. */
|
|
@@ -46,20 +46,20 @@ git interpret-trailers --in-place --trailer ${options.trailerExpr} "$msg_file"
|
|
|
46
46
|
* hook. A no-op when this adapter's hook is already installed.
|
|
47
47
|
*/
|
|
48
48
|
export function installCommitHook(repoRoot, options) {
|
|
49
|
+
const layout = locateHooks(repoRoot);
|
|
50
|
+
if (!layout)
|
|
51
|
+
return { installed: false, reason: "not a git repo" };
|
|
52
|
+
return installInto(layout, options);
|
|
53
|
+
}
|
|
54
|
+
/** Install the hook into a repo whose hooks have been located. */
|
|
55
|
+
function installInto({ hooksDir, customHooksPath }, options) {
|
|
49
56
|
// A custom core.hooksPath means a hook manager (husky and the
|
|
50
57
|
// like) or a shared, possibly version-controlled hooks directory
|
|
51
58
|
// owns the hooks. Leave it alone rather than write this adapter's
|
|
52
59
|
// hook into a directory it does not own.
|
|
53
|
-
if (
|
|
60
|
+
if (customHooksPath) {
|
|
54
61
|
return { installed: false, reason: "custom core.hooksPath configured" };
|
|
55
62
|
}
|
|
56
|
-
let hooksDir;
|
|
57
|
-
try {
|
|
58
|
-
hooksDir = resolveHooksDir(repoRoot);
|
|
59
|
-
}
|
|
60
|
-
catch (error) {
|
|
61
|
-
return { installed: false, reason: `not a git repo: ${String(error)}` };
|
|
62
|
-
}
|
|
63
63
|
const target = join(hooksDir, "prepare-commit-msg");
|
|
64
64
|
if (existsSync(target) &&
|
|
65
65
|
readFileSync(target, "utf8").includes(options.marker)) {
|
|
@@ -82,41 +82,66 @@ export function installCommitHook(repoRoot, options) {
|
|
|
82
82
|
chmodSync(target, 0o755);
|
|
83
83
|
return { installed: true };
|
|
84
84
|
}
|
|
85
|
-
/** Whether the repo configures a custom core.hooksPath. */
|
|
86
|
-
function hasCustomHooksPath(repoRoot) {
|
|
87
|
-
try {
|
|
88
|
-
const value = execFileSync("git", ["-C", repoRoot, "config", "--get", "core.hooksPath"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
89
|
-
return value.length > 0;
|
|
90
|
-
}
|
|
91
|
-
catch {
|
|
92
|
-
// git config exits non-zero when the key is unset: no custom path.
|
|
93
|
-
return false;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
85
|
/**
|
|
97
86
|
* Ensure the hook is installed in the repo containing dir, at most
|
|
98
|
-
* once per repo
|
|
99
|
-
* so later
|
|
100
|
-
* best-effort. A directory outside any git repo is a no-op
|
|
101
|
-
*
|
|
102
|
-
*
|
|
87
|
+
* once per repo. Records both dir and its repo root in `installed`,
|
|
88
|
+
* so a later command from either asks git nothing, and installs
|
|
89
|
+
* best-effort. A directory outside any git repo is a no-op and is
|
|
90
|
+
* not remembered, so a repo initialised there later is still
|
|
91
|
+
* covered. This is how hook coverage follows the session into repos
|
|
92
|
+
* it later cds into, rather than only the repo the session started
|
|
93
|
+
* in. Each git call is a synchronous spawn on the command path, so
|
|
94
|
+
* a first visit costs one and a repeat costs none.
|
|
103
95
|
*/
|
|
104
96
|
export function ensureCommitHook(dir, installed, options) {
|
|
105
|
-
|
|
106
|
-
|
|
97
|
+
if (installed.has(dir))
|
|
98
|
+
return;
|
|
99
|
+
const layout = locateHooks(dir);
|
|
100
|
+
if (!layout)
|
|
107
101
|
return;
|
|
108
|
-
installed.add(
|
|
102
|
+
installed.add(dir);
|
|
103
|
+
if (installed.has(layout.root))
|
|
104
|
+
return;
|
|
105
|
+
installed.add(layout.root);
|
|
109
106
|
try {
|
|
110
|
-
|
|
107
|
+
installInto(layout, options);
|
|
111
108
|
}
|
|
112
109
|
catch {
|
|
113
110
|
// Best-effort: never let hook installation break a command.
|
|
114
111
|
}
|
|
115
112
|
}
|
|
116
|
-
/**
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
113
|
+
/**
|
|
114
|
+
* Locate the hooks of the repo containing dir with one git call, or
|
|
115
|
+
* null when dir is in no working tree. Git prints the default hooks
|
|
116
|
+
* path as the common dir plus `/hooks`, in the same form, so any
|
|
117
|
+
* other answer means core.hooksPath points somewhere else.
|
|
118
|
+
*/
|
|
119
|
+
function locateHooks(dir) {
|
|
120
|
+
let answer;
|
|
121
|
+
try {
|
|
122
|
+
answer = execFileSync("git", [
|
|
123
|
+
"-C",
|
|
124
|
+
dir,
|
|
125
|
+
"rev-parse",
|
|
126
|
+
"--show-toplevel",
|
|
127
|
+
"--git-common-dir",
|
|
128
|
+
"--git-path",
|
|
129
|
+
"hooks",
|
|
130
|
+
], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// Not in a working tree (or git unavailable): nothing to hook.
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const [root, commonDir, hooks] = answer.trimEnd().split("\n");
|
|
137
|
+
if (!root || !commonDir || !hooks)
|
|
138
|
+
return null;
|
|
139
|
+
return {
|
|
140
|
+
root,
|
|
141
|
+
// Relative paths are relative to the directory git ran in.
|
|
142
|
+
hooksDir: isAbsolute(hooks) ? hooks : join(dir, hooks),
|
|
143
|
+
customHooksPath: hooks !== `${commonDir}/hooks`,
|
|
144
|
+
};
|
|
120
145
|
}
|
|
121
146
|
/** The git repository root containing dir, or null when there is none. */
|
|
122
147
|
export function repoRootOf(dir) {
|
|
@@ -79,6 +79,11 @@ export declare function identityFromInspection(hostId: string, pid: number, insp
|
|
|
79
79
|
* read a real start token: a session then carries no process identity
|
|
80
80
|
* and its liveness falls back to recency, rather than a synthetic
|
|
81
81
|
* token that a later probe would read as a dead mismatch.
|
|
82
|
+
*
|
|
83
|
+
* A process's own start token cannot change while it runs, so the
|
|
84
|
+
* first real reading is remembered: a session start attaches more
|
|
85
|
+
* than once and each reading is a synchronous `ps`. A failed reading
|
|
86
|
+
* is not remembered, so a transient failure is retried next time.
|
|
82
87
|
*/
|
|
83
88
|
export declare function currentProcessIdentity(): ProcessIdentity | undefined;
|
|
84
89
|
/**
|
|
@@ -95,14 +95,26 @@ export function identityFromInspection(hostId, pid, inspection) {
|
|
|
95
95
|
? { hostId, pid, startToken: inspection.startToken }
|
|
96
96
|
: undefined;
|
|
97
97
|
}
|
|
98
|
+
/** Memoized identity of this process, once one has been read. */
|
|
99
|
+
let processIdentityCache;
|
|
98
100
|
/**
|
|
99
101
|
* Identity of the currently running pi process, for capture onto the
|
|
100
102
|
* session it is attached to. Undefined when the OS reader could not
|
|
101
103
|
* read a real start token: a session then carries no process identity
|
|
102
104
|
* and its liveness falls back to recency, rather than a synthetic
|
|
103
105
|
* token that a later probe would read as a dead mismatch.
|
|
106
|
+
*
|
|
107
|
+
* A process's own start token cannot change while it runs, so the
|
|
108
|
+
* first real reading is remembered: a session start attaches more
|
|
109
|
+
* than once and each reading is a synchronous `ps`. A failed reading
|
|
110
|
+
* is not remembered, so a transient failure is retried next time.
|
|
104
111
|
*/
|
|
105
112
|
export function currentProcessIdentity() {
|
|
113
|
+
processIdentityCache ??= readProcessIdentity();
|
|
114
|
+
return processIdentityCache;
|
|
115
|
+
}
|
|
116
|
+
/** Read this process's identity from the OS. */
|
|
117
|
+
function readProcessIdentity() {
|
|
106
118
|
const identity = identityFromInspection(hostname(), process.pid, readStartToken(process.pid));
|
|
107
119
|
if (!identity)
|
|
108
120
|
return undefined;
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* file. When nothing resolves, it fails with a clear message
|
|
12
12
|
* rather than a cryptic one.
|
|
13
13
|
*
|
|
14
|
+
* A server nobody has called for `idleMs` is stopped and dropped
|
|
15
|
+
* from the pool, and the next call for its root starts a fresh
|
|
16
|
+
* one: a TypeScript server holds hundreds of megabytes, and a long
|
|
17
|
+
* session touches many roots it never returns to. A call in
|
|
18
|
+
* flight keeps its servers alive however long it takes.
|
|
19
|
+
*
|
|
14
20
|
* The live server pool lives in this closure's memory for the
|
|
15
21
|
* life of the process that constructs it. A stateless-per-call
|
|
16
22
|
* CLI adapter that wants warm servers across invocations needs
|
|
@@ -30,6 +36,8 @@ export interface StandaloneBackendOptions {
|
|
|
30
36
|
readonly servers?: Readonly<Record<string, ServerConfig>>;
|
|
31
37
|
/** Environment used for PATH resolution. Defaults to process.env. */
|
|
32
38
|
readonly env?: NodeJS.ProcessEnv;
|
|
39
|
+
/** How long a server may sit unused before it is stopped. */
|
|
40
|
+
readonly idleMs?: number;
|
|
33
41
|
}
|
|
34
42
|
/** A standalone backend with visibility into its live pool. */
|
|
35
43
|
export interface StandaloneBackend extends LspBackend {
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* file. When nothing resolves, it fails with a clear message
|
|
12
12
|
* rather than a cryptic one.
|
|
13
13
|
*
|
|
14
|
+
* A server nobody has called for `idleMs` is stopped and dropped
|
|
15
|
+
* from the pool, and the next call for its root starts a fresh
|
|
16
|
+
* one: a TypeScript server holds hundreds of megabytes, and a long
|
|
17
|
+
* session touches many roots it never returns to. A call in
|
|
18
|
+
* flight keeps its servers alive however long it takes.
|
|
19
|
+
*
|
|
14
20
|
* The live server pool lives in this closure's memory for the
|
|
15
21
|
* life of the process that constructs it. A stateless-per-call
|
|
16
22
|
* CLI adapter that wants warm servers across invocations needs
|
|
@@ -28,20 +34,56 @@ export class MissingServerError extends Error {
|
|
|
28
34
|
this.name = "MissingServerError";
|
|
29
35
|
}
|
|
30
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Default idle window. Long enough that a burst of work on one
|
|
39
|
+
* project keeps its server warm, short enough that a project left
|
|
40
|
+
* behind gives its memory back within the hour.
|
|
41
|
+
*/
|
|
42
|
+
const DEFAULT_IDLE_MS = 10 * 60_000;
|
|
31
43
|
/** Construct a standalone backend over the given (or default) server map. */
|
|
32
44
|
export function createStandaloneBackend(options = {}) {
|
|
33
45
|
const servers = options.servers ?? DEFAULT_SERVERS;
|
|
34
46
|
const env = options.env ?? process.env;
|
|
47
|
+
const idleMs = options.idleMs ?? DEFAULT_IDLE_MS;
|
|
35
48
|
const pool = new Map();
|
|
49
|
+
// Calls in flight per pool key, and the stop timer armed when a
|
|
50
|
+
// key's last call finishes.
|
|
51
|
+
const inFlight = new Map();
|
|
52
|
+
const idleTimers = new Map();
|
|
36
53
|
const poolKey = (name, root) => `${name}|${root}`;
|
|
54
|
+
const claim = (key) => {
|
|
55
|
+
inFlight.set(key, (inFlight.get(key) ?? 0) + 1);
|
|
56
|
+
clearTimeout(idleTimers.get(key));
|
|
57
|
+
idleTimers.delete(key);
|
|
58
|
+
};
|
|
59
|
+
const release = (key) => {
|
|
60
|
+
const left = (inFlight.get(key) ?? 1) - 1;
|
|
61
|
+
if (left > 0) {
|
|
62
|
+
inFlight.set(key, left);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
inFlight.delete(key);
|
|
66
|
+
const timer = setTimeout(() => stopIdle(key), idleMs);
|
|
67
|
+
// An idle server must never be what keeps the process alive.
|
|
68
|
+
timer.unref();
|
|
69
|
+
idleTimers.set(key, timer);
|
|
70
|
+
};
|
|
71
|
+
const stopIdle = (key) => {
|
|
72
|
+
idleTimers.delete(key);
|
|
73
|
+
if (inFlight.has(key))
|
|
74
|
+
return;
|
|
75
|
+
const started = pool.get(key);
|
|
76
|
+
pool.delete(key);
|
|
77
|
+
void started?.then((server) => server.dispose(), () => { });
|
|
78
|
+
};
|
|
37
79
|
const instanceFor = (server, root, binary) => {
|
|
38
80
|
const key = poolKey(server.name, root);
|
|
39
81
|
const existing = pool.get(key);
|
|
40
82
|
if (existing)
|
|
41
|
-
return existing;
|
|
83
|
+
return { key, started: existing };
|
|
42
84
|
const started = StandaloneServer.start(server, root, binary);
|
|
43
85
|
pool.set(key, started);
|
|
44
|
-
return started;
|
|
86
|
+
return { key, started };
|
|
45
87
|
};
|
|
46
88
|
// Resolve a server's effective command and args for a root. A
|
|
47
89
|
// server with a `resolve` hook picks its binary per project (the
|
|
@@ -65,7 +107,22 @@ export function createStandaloneBackend(options = {}) {
|
|
|
65
107
|
binary,
|
|
66
108
|
};
|
|
67
109
|
};
|
|
68
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Run fn against the servers for a file, holding each one's pool
|
|
112
|
+
* key claimed from before it resolves until fn settles, so no
|
|
113
|
+
* server is stopped under a call that is using it.
|
|
114
|
+
*/
|
|
115
|
+
const withInstances = async (filePath, typeOnly, fn) => {
|
|
116
|
+
const claimed = [];
|
|
117
|
+
try {
|
|
118
|
+
return await fn(await resolveInstances(filePath, typeOnly, claimed));
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
for (const key of claimed)
|
|
122
|
+
release(key);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
const resolveInstances = async (filePath, typeOnly, claimed) => {
|
|
69
126
|
const candidates = serversForFile(filePath, servers).filter((server) => !typeOnly || !server.isLinter);
|
|
70
127
|
const instances = [];
|
|
71
128
|
const reasons = [];
|
|
@@ -80,7 +137,10 @@ export function createStandaloneBackend(options = {}) {
|
|
|
80
137
|
reasons.push(eff.reason);
|
|
81
138
|
continue;
|
|
82
139
|
}
|
|
83
|
-
|
|
140
|
+
const { key, started } = instanceFor(eff.config, root, eff.binary);
|
|
141
|
+
claim(key);
|
|
142
|
+
claimed.push(key);
|
|
143
|
+
instances.push(await started);
|
|
84
144
|
if (typeOnly)
|
|
85
145
|
break;
|
|
86
146
|
}
|
|
@@ -95,40 +155,46 @@ export function createStandaloneBackend(options = {}) {
|
|
|
95
155
|
return {
|
|
96
156
|
name: "standalone",
|
|
97
157
|
async diagnostics(path) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
158
|
+
return withInstances(path, false, async (instances) => {
|
|
159
|
+
const results = await Promise.all(instances.map((s) => s.diagnose(path)));
|
|
160
|
+
return results.flat();
|
|
161
|
+
});
|
|
101
162
|
},
|
|
102
163
|
async definition(target) {
|
|
103
|
-
|
|
104
|
-
return server.definition(target);
|
|
164
|
+
return withInstances(target.path, true, ([server]) => server.definition(target));
|
|
105
165
|
},
|
|
106
166
|
async references(target) {
|
|
107
|
-
|
|
108
|
-
return server.references(target);
|
|
167
|
+
return withInstances(target.path, true, ([server]) => server.references(target));
|
|
109
168
|
},
|
|
110
169
|
async hover(target) {
|
|
111
|
-
|
|
112
|
-
return server.hover(target);
|
|
170
|
+
return withInstances(target.path, true, ([server]) => server.hover(target));
|
|
113
171
|
},
|
|
114
172
|
async documentSymbols(path) {
|
|
115
|
-
|
|
116
|
-
return server.documentSymbols(path);
|
|
173
|
+
return withInstances(path, true, ([server]) => server.documentSymbols(path));
|
|
117
174
|
},
|
|
118
175
|
async workspaceSymbols(query) {
|
|
119
176
|
// Workspace symbols carry no file, so they search every
|
|
120
|
-
// server already running; nothing is spawned on demand
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
177
|
+
// server already running; nothing is spawned on demand, and
|
|
178
|
+
// a server stopped for idleness is not searched until
|
|
179
|
+
// something file-bound starts it again.
|
|
180
|
+
const keys = [...pool.keys()];
|
|
181
|
+
for (const key of keys)
|
|
182
|
+
claim(key);
|
|
183
|
+
try {
|
|
184
|
+
const live = await Promise.all(keys.map((key) => pool.get(key)));
|
|
185
|
+
const results = await Promise.all(live.map((server) => server?.workspaceSymbols(query) ?? []));
|
|
186
|
+
return results.flat();
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
for (const key of keys)
|
|
190
|
+
release(key);
|
|
191
|
+
}
|
|
124
192
|
},
|
|
125
193
|
async rename(target, newName) {
|
|
126
|
-
|
|
127
|
-
return server.rename(target, newName);
|
|
194
|
+
return withInstances(target.path, true, ([server]) => server.rename(target, newName));
|
|
128
195
|
},
|
|
129
196
|
async codeActions(path, range) {
|
|
130
|
-
|
|
131
|
-
return server.codeActions(path, range);
|
|
197
|
+
return withInstances(path, true, ([server]) => server.codeActions(path, range));
|
|
132
198
|
},
|
|
133
199
|
syncDocument(path, text) {
|
|
134
200
|
for (const started of pool.values()) {
|
|
@@ -142,6 +208,9 @@ export function createStandaloneBackend(options = {}) {
|
|
|
142
208
|
return pool.size;
|
|
143
209
|
},
|
|
144
210
|
async dispose() {
|
|
211
|
+
for (const timer of idleTimers.values())
|
|
212
|
+
clearTimeout(timer);
|
|
213
|
+
idleTimers.clear();
|
|
145
214
|
const started = [...pool.values()];
|
|
146
215
|
pool.clear();
|
|
147
216
|
await Promise.all(started.map((s) => s.then((server) => server.dispose())));
|
package/package.json
CHANGED