jeo-code 0.7.31 → 0.7.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.ja.md +4 -2
- package/README.ko.md +4 -2
- package/README.md +4 -2
- package/README.zh.md +4 -2
- package/package.json +4 -4
- package/scripts/install.sh +19 -0
- package/scripts/uninstall.sh +2 -2
- package/src/agent/AGENTS.md +15 -0
- package/src/agent/ast-edit-tool.ts +123 -0
- package/src/agent/ast-file-scan.ts +62 -0
- package/src/agent/ast-grep-tool.ts +107 -0
- package/src/agent/ast-match.ts +241 -0
- package/src/agent/browser-session.ts +104 -0
- package/src/agent/browser-tab.ts +145 -0
- package/src/agent/browser-tool.ts +188 -0
- package/src/agent/debug-session.ts +435 -0
- package/src/agent/debug-tool.ts +89 -0
- package/src/agent/engine.ts +72 -12
- package/src/agent/goal-tool.ts +57 -0
- package/src/agent/irc-tool.ts +71 -0
- package/src/agent/job-registry.ts +163 -0
- package/src/agent/job-tool.ts +106 -0
- package/src/agent/lsp-rename-tool.ts +108 -0
- package/src/agent/lsp-tool.ts +177 -0
- package/src/agent/session.ts +27 -0
- package/src/agent/subagent-registry.ts +40 -4
- package/src/agent/subagent-tool.ts +27 -7
- package/src/agent/subagents.ts +1 -1
- package/src/agent/task-tool.ts +27 -5
- package/src/agent/tools.ts +68 -1
- package/src/agent/ts-language-service.ts +173 -0
- package/src/commands/launch/agents-slash.ts +489 -0
- package/src/commands/launch/flags.ts +27 -9
- package/src/commands/launch/model-slash.ts +290 -0
- package/src/commands/launch/session-slash.ts +305 -0
- package/src/commands/launch/slash-handlers.ts +6 -0
- package/src/commands/launch/system-slash.ts +81 -0
- package/src/commands/launch.ts +208 -749
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `goal` tool — lets the agent explicitly set/read/clear the session's natural-
|
|
3
|
+
* language stop condition, mirroring gjc's own `goal` tool. Wraps the existing
|
|
4
|
+
* file-persisted GoalState (src/agent/goal-verifier.ts) that the human-typed
|
|
5
|
+
* `/goal <condition>` slash command already writes to and that the engine's
|
|
6
|
+
* per-`done` verifier (src/commands/launch.ts, verifyGoal) already reads from —
|
|
7
|
+
* this tool just gives the MODEL the same lever, not a new state store.
|
|
8
|
+
*/
|
|
9
|
+
import type { ToolHandler } from "./engine";
|
|
10
|
+
import type { ToolResult } from "./tools";
|
|
11
|
+
import { readGoalState, writeGoalState, clearGoalState, type GoalState } from "./goal-verifier";
|
|
12
|
+
|
|
13
|
+
/** One-line protocol description appended to the launch system prompt. */
|
|
14
|
+
export const GOAL_TOOL_PROTOCOL_LINE =
|
|
15
|
+
`goal {action:"set", condition} sets a natural-language stop condition for this session — ` +
|
|
16
|
+
`it is auto-verified against the conversation every time you call done, and a NOT_MET/IMPOSSIBLE ` +
|
|
17
|
+
`verdict blocks your reply and tells you what's missing. goal {action:"get"} shows the current ` +
|
|
18
|
+
`condition and its latest verdict. goal {action:"clear"} removes it.`;
|
|
19
|
+
|
|
20
|
+
function formatState(state: GoalState | null): string {
|
|
21
|
+
if (!state) return `No goal set for this session. Set one with goal {action:"set", condition}.`;
|
|
22
|
+
const last = state.verdicts[state.verdicts.length - 1];
|
|
23
|
+
const verdictLine = last
|
|
24
|
+
? `Last verdict: ${last.verdict}${last.gap ? ` — ${last.gap}` : ""}`
|
|
25
|
+
: "No verdicts yet (verified on your next done call).";
|
|
26
|
+
return `Goal: "${state.condition}" (set ${new Date(state.setAt).toLocaleTimeString()})\n${verdictLine}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createGoalTool(): ToolHandler {
|
|
30
|
+
return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
|
|
31
|
+
const action = String(args.action ?? "get").trim().toLowerCase();
|
|
32
|
+
|
|
33
|
+
if (action === "set") {
|
|
34
|
+
const condition = typeof args.condition === "string" ? args.condition.trim() : "";
|
|
35
|
+
if (!condition) {
|
|
36
|
+
return { success: false, output: "", error: `goal {action:"set"} requires a non-empty 'condition'.` };
|
|
37
|
+
}
|
|
38
|
+
await writeGoalState({ condition, setAt: Date.now(), verdicts: [] }, cwd);
|
|
39
|
+
return {
|
|
40
|
+
success: true,
|
|
41
|
+
output: `Goal set: "${condition}". It will be auto-verified against the conversation each time you call done.`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (action === "clear") {
|
|
46
|
+
await clearGoalState(cwd);
|
|
47
|
+
return { success: true, output: "Goal cleared." };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (action === "get") {
|
|
51
|
+
const state = await readGoalState(cwd);
|
|
52
|
+
return { success: true, output: formatState(state) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { success: false, output: "", error: `Unknown goal action '${action}'. Use set | get | clear.` };
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `irc` tool — lets the PARENT list running DETACHED subagents as "peers" and send
|
|
3
|
+
* a live message to one or all of them (gjc `irc` parity, scoped to an in-process
|
|
4
|
+
* registry: no separate peer directory, no channels — a running detached subagent
|
|
5
|
+
* IS a peer). Built entirely on `SubagentRegistry.running()` / `.steer()`; this file
|
|
6
|
+
* holds no state of its own.
|
|
7
|
+
*
|
|
8
|
+
* Delivery mechanism is identical to `subagent {action:"steer"}` — a pushed message
|
|
9
|
+
* sits in that subagent's registry inbox until its own agent loop drains it between
|
|
10
|
+
* steps (see `subagent-registry.ts` steer()/steerDrainFor()). "send to all" simply
|
|
11
|
+
* loops registry.steer() over every currently running id.
|
|
12
|
+
*/
|
|
13
|
+
import type { ToolHandler } from "./engine";
|
|
14
|
+
import type { ToolResult } from "./tools";
|
|
15
|
+
import type { SubagentRegistry, SubagentRecord } from "./subagent-registry";
|
|
16
|
+
|
|
17
|
+
/** One-line protocol description appended to the launch system prompt. */
|
|
18
|
+
export const IRC_TOOL_PROTOCOL_LINE =
|
|
19
|
+
`irc {action:"list"|"send", to?, message?} — live peer messaging with DETACHED ` +
|
|
20
|
+
`subagents started with task{detached:true}. 'list' shows running subagents as peers; ` +
|
|
21
|
+
`'send' delivers {to, message} to one running peer (to: its id) or every running peer ` +
|
|
22
|
+
`(to:"all"), picked up before its next step.`;
|
|
23
|
+
|
|
24
|
+
function peerLine(rec: SubagentRecord): string {
|
|
25
|
+
return `- ${rec.id} (${rec.role}) · ${rec.task}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createIrcTool(registry: SubagentRegistry): ToolHandler {
|
|
29
|
+
return async (args: Record<string, any>, _cwd: string): Promise<ToolResult> => {
|
|
30
|
+
const action = String(args.action ?? "list").trim().toLowerCase();
|
|
31
|
+
|
|
32
|
+
if (action === "list") {
|
|
33
|
+
const peers = registry.running();
|
|
34
|
+
if (peers.length === 0) {
|
|
35
|
+
return { success: true, output: "No running subagent peers. Launch one with task {detached:true}." };
|
|
36
|
+
}
|
|
37
|
+
return { success: true, output: peers.map(peerLine).join("\n") };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (action === "send") {
|
|
41
|
+
const message = typeof args.message === "string" ? args.message : typeof args.text === "string" ? args.text : "";
|
|
42
|
+
const to = typeof args.to === "string" ? args.to.trim() : "";
|
|
43
|
+
if (!message.trim()) {
|
|
44
|
+
return { success: false, output: "", error: "irc 'send' requires a non-empty 'message' (or 'text')." };
|
|
45
|
+
}
|
|
46
|
+
if (!to) {
|
|
47
|
+
return { success: false, output: "", error: "irc 'send' requires a 'to' target: a running subagent id, or \"all\"." };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (to === "all") {
|
|
51
|
+
const peers = registry.running();
|
|
52
|
+
let sent = 0;
|
|
53
|
+
for (const peer of peers) {
|
|
54
|
+
if (registry.steer(peer.id, message)) sent++;
|
|
55
|
+
}
|
|
56
|
+
if (sent === 0) {
|
|
57
|
+
return { success: false, output: "", error: "No running subagent peers to send to." };
|
|
58
|
+
}
|
|
59
|
+
return { success: true, output: `Sent to ${sent} running peer(s).` };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const ok = registry.steer(to, message);
|
|
63
|
+
if (!ok) {
|
|
64
|
+
return { success: false, output: "", error: `No running subagent peer '${to}'.` };
|
|
65
|
+
}
|
|
66
|
+
return { success: true, output: `Sent to ${to}.` };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { success: false, output: "", error: `Unknown irc action '${action}'. Use list | send.` };
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process registry for BACKGROUND shell processes (gjc `job`/async-bash parity,
|
|
3
|
+
* scoped down to one runtime). Unlike the synchronous `bash` tool, `start()` spawns
|
|
4
|
+
* a real parallel OS process via `Bun.spawn` and returns immediately — the parent
|
|
5
|
+
* can keep working and later list / tail / await / cancel it via the `job` control
|
|
6
|
+
* tool while stdout/stderr drain into a bounded buffer in the background.
|
|
7
|
+
*
|
|
8
|
+
* Lifecycle is bounded to the turn that created the registry — `cancelAll()` on
|
|
9
|
+
* turn teardown guarantees no background process leaks into the next turn.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type JobStatus = "running" | "exited" | "killed" | "failed";
|
|
13
|
+
|
|
14
|
+
export interface JobRecord {
|
|
15
|
+
/** Stable id, e.g. "job-1". */
|
|
16
|
+
id: string;
|
|
17
|
+
command: string;
|
|
18
|
+
cwd: string;
|
|
19
|
+
status: JobStatus;
|
|
20
|
+
startedAt: number;
|
|
21
|
+
finishedAt?: number;
|
|
22
|
+
exitCode?: number | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Entry {
|
|
26
|
+
record: JobRecord;
|
|
27
|
+
proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
|
|
28
|
+
promise: Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const OUTPUT_CAP = 20000;
|
|
32
|
+
const TRUNCATION_MARKER = "[…output truncated…]\n";
|
|
33
|
+
|
|
34
|
+
export class JobRegistry {
|
|
35
|
+
private readonly entries = new Map<string, Entry>();
|
|
36
|
+
private readonly buffers = new Map<string, string>();
|
|
37
|
+
private readonly truncated = new Set<string>();
|
|
38
|
+
private seq = 0;
|
|
39
|
+
|
|
40
|
+
private append(id: string, chunk: string): void {
|
|
41
|
+
if (!chunk) return;
|
|
42
|
+
let buf = (this.buffers.get(id) ?? "") + chunk;
|
|
43
|
+
if (buf.length > OUTPUT_CAP) {
|
|
44
|
+
buf = buf.slice(buf.length - OUTPUT_CAP);
|
|
45
|
+
if (!this.truncated.has(id)) {
|
|
46
|
+
this.truncated.add(id);
|
|
47
|
+
buf = TRUNCATION_MARKER + buf;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
this.buffers.set(id, buf);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Spawn and START a background OS process; returns the (running) record immediately. */
|
|
54
|
+
start(command: string, cwd: string): JobRecord {
|
|
55
|
+
this.seq += 1;
|
|
56
|
+
const id = `job-${this.seq}`;
|
|
57
|
+
const record: JobRecord = {
|
|
58
|
+
id,
|
|
59
|
+
command,
|
|
60
|
+
cwd,
|
|
61
|
+
status: "running",
|
|
62
|
+
startedAt: Date.now(),
|
|
63
|
+
};
|
|
64
|
+
this.buffers.set(id, "");
|
|
65
|
+
|
|
66
|
+
let proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
|
|
67
|
+
try {
|
|
68
|
+
proc = Bun.spawn(["bash", "-c", command], { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
69
|
+
} catch (err) {
|
|
70
|
+
record.status = "failed";
|
|
71
|
+
record.finishedAt = Date.now();
|
|
72
|
+
this.append(id, err instanceof Error ? err.message : String(err));
|
|
73
|
+
// No process handle to store — nothing further to drain or cancel.
|
|
74
|
+
this.entries.set(id, { record, proc: undefined as any, promise: Promise.resolve() });
|
|
75
|
+
return record;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const drain = async (stream: ReadableStream<Uint8Array>) => {
|
|
79
|
+
const decoder = new TextDecoder();
|
|
80
|
+
for await (const chunk of stream) {
|
|
81
|
+
this.append(id, decoder.decode(chunk, { stream: true }));
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const promise = (async () => {
|
|
86
|
+
try {
|
|
87
|
+
await Promise.all([drain(proc.stdout), drain(proc.stderr)]);
|
|
88
|
+
const exitCode = await proc.exited;
|
|
89
|
+
if (record.status === "killed") return;
|
|
90
|
+
record.status = "exited";
|
|
91
|
+
record.exitCode = exitCode;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (record.status === "killed") return;
|
|
94
|
+
record.status = "failed";
|
|
95
|
+
this.append(id, err instanceof Error ? err.message : String(err));
|
|
96
|
+
} finally {
|
|
97
|
+
if (record.finishedAt === undefined) record.finishedAt = Date.now();
|
|
98
|
+
}
|
|
99
|
+
})();
|
|
100
|
+
|
|
101
|
+
this.entries.set(id, { record, proc, promise });
|
|
102
|
+
return record;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
list(): JobRecord[] {
|
|
106
|
+
return [...this.entries.values()].map(e => e.record);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
get(id: string): JobRecord | undefined {
|
|
110
|
+
return this.entries.get(id)?.record;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
running(): JobRecord[] {
|
|
114
|
+
return this.list().filter(r => r.status === "running");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Current buffered output for one job (empty string if the id is unknown). */
|
|
118
|
+
tail(id: string): string {
|
|
119
|
+
return this.buffers.get(id) ?? "";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Wait for the given ids. With `timeoutMs` the wait is bounded — unfinished jobs
|
|
123
|
+
* simply stay "running" in the returned snapshot. */
|
|
124
|
+
async awaitIds(ids: string[], timeoutMs?: number): Promise<JobRecord[]> {
|
|
125
|
+
const targets = ids
|
|
126
|
+
.map(id => this.entries.get(id))
|
|
127
|
+
.filter((e): e is Entry => e !== undefined);
|
|
128
|
+
const all = Promise.all(targets.map(e => e.promise)).then(() => {});
|
|
129
|
+
if (timeoutMs !== undefined && timeoutMs > 0) {
|
|
130
|
+
let handle: ReturnType<typeof setTimeout> | undefined;
|
|
131
|
+
const timer = new Promise<void>(resolve => {
|
|
132
|
+
handle = setTimeout(resolve, timeoutMs);
|
|
133
|
+
});
|
|
134
|
+
await Promise.race([all, timer]);
|
|
135
|
+
if (handle !== undefined) clearTimeout(handle);
|
|
136
|
+
} else {
|
|
137
|
+
await all;
|
|
138
|
+
}
|
|
139
|
+
return targets.map(e => e.record);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Cancel the given ids: SIGTERM via `proc.kill()` and mark killed. Already-terminal
|
|
143
|
+
* records are returned unchanged. */
|
|
144
|
+
cancel(ids: string[]): JobRecord[] {
|
|
145
|
+
const out: JobRecord[] = [];
|
|
146
|
+
for (const id of ids) {
|
|
147
|
+
const e = this.entries.get(id);
|
|
148
|
+
if (!e) continue;
|
|
149
|
+
if (e.record.status === "running") {
|
|
150
|
+
e.record.status = "killed";
|
|
151
|
+
e.record.finishedAt = Date.now();
|
|
152
|
+
e.proc?.kill();
|
|
153
|
+
}
|
|
154
|
+
out.push(e.record);
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Kill every still-running job (turn teardown / Ctrl-C). */
|
|
160
|
+
cancelAll(): JobRecord[] {
|
|
161
|
+
return this.cancel(this.running().map(r => r.id));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `job` control tool — the parent's handle on BACKGROUND shell processes launched
|
|
3
|
+
* via `job {action:"start"}`. Mirrors gjc's `job`/async-bash control surface, scoped
|
|
4
|
+
* to an in-process registry: start, list, tail, await (optionally bounded), and cancel.
|
|
5
|
+
*/
|
|
6
|
+
import type { ToolHandler } from "./engine";
|
|
7
|
+
import type { ToolResult } from "./tools";
|
|
8
|
+
import type { JobRegistry, JobRecord } from "./job-registry";
|
|
9
|
+
|
|
10
|
+
/** One-line protocol description appended to the launch system prompt. */
|
|
11
|
+
export const JOB_TOOL_PROTOCOL_LINE =
|
|
12
|
+
`job {action:"start", command, cwd?} runs a shell command in the BACKGROUND as a real ` +
|
|
13
|
+
`parallel OS process and returns a job id immediately. job {action:"list"|"tail"|"await"|"cancel", ` +
|
|
14
|
+
`ids?, timeoutMs?} — 'tail' shows buffered output so far; 'await' blocks (optionally up to timeoutMs ` +
|
|
15
|
+
`ms) until it exits; 'cancel' kills it; omit ids to target all running.`;
|
|
16
|
+
|
|
17
|
+
function truncate(s: string, n: number): string {
|
|
18
|
+
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function elapsed(rec: JobRecord): string {
|
|
22
|
+
const end = rec.finishedAt ?? Date.now();
|
|
23
|
+
return `${Math.max(0, Math.round((end - rec.startedAt) / 1000))}s`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function rowLine(rec: JobRecord): string {
|
|
27
|
+
return `- ${rec.id} [${rec.status.toUpperCase()}] ${elapsed(rec)} · ${truncate(rec.command, 100)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function idsOf(args: Record<string, any>): string[] {
|
|
31
|
+
if (Array.isArray(args.ids)) return args.ids.map((x: unknown) => String(x));
|
|
32
|
+
if (args.id !== undefined) return [String(args.id)];
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createJobTool(registry: JobRegistry): ToolHandler {
|
|
37
|
+
return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
|
|
38
|
+
const action = String(args.action ?? "list").trim().toLowerCase();
|
|
39
|
+
const ids = idsOf(args);
|
|
40
|
+
|
|
41
|
+
if (action === "start") {
|
|
42
|
+
const command = typeof args.command === "string" ? args.command.trim() : "";
|
|
43
|
+
if (!command) {
|
|
44
|
+
return { success: false, output: "", error: `job {action:"start"} requires a non-empty 'command'.` };
|
|
45
|
+
}
|
|
46
|
+
const resolvedCwd = typeof args.cwd === "string" && args.cwd.trim() ? args.cwd : cwd;
|
|
47
|
+
const rec = registry.start(command, resolvedCwd);
|
|
48
|
+
return {
|
|
49
|
+
success: true,
|
|
50
|
+
output: `[background] started job '${rec.id}': ${truncate(command, 120)}. It runs in the background — ` +
|
|
51
|
+
`keep working, then use the 'job' tool ({action:"tail"|"await"|"list"|"cancel", ids?}) to check on it.`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (action === "list") {
|
|
56
|
+
const rows = registry.list();
|
|
57
|
+
if (rows.length === 0) {
|
|
58
|
+
return { success: true, output: `No background jobs this turn. Launch one with job {action:"start", command}.` };
|
|
59
|
+
}
|
|
60
|
+
const running = rows.filter(r => r.status === "running").length;
|
|
61
|
+
return { success: true, output: `${rows.length} job(s), ${running} running:\n${rows.map(rowLine).join("\n")}` };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (action === "tail") {
|
|
65
|
+
const targets = ids.length ? ids : registry.running().map(r => r.id);
|
|
66
|
+
if (targets.length === 0) {
|
|
67
|
+
return { success: true, output: "No running jobs to tail." };
|
|
68
|
+
}
|
|
69
|
+
const unknown = targets.filter(id => !registry.get(id));
|
|
70
|
+
if (unknown.length > 0) {
|
|
71
|
+
return { success: false, output: "", error: `No job matches ${unknown.join(", ")}.` };
|
|
72
|
+
}
|
|
73
|
+
const blocks = targets.map(id => {
|
|
74
|
+
const rec = registry.get(id)!;
|
|
75
|
+
const out = registry.tail(id);
|
|
76
|
+
return `- ${rec.id} [${rec.status.toUpperCase()}]:\n${out || "(no output yet)"}`;
|
|
77
|
+
});
|
|
78
|
+
return { success: true, output: blocks.join("\n\n") };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (action === "await") {
|
|
82
|
+
const targets = ids.length ? ids : registry.running().map(r => r.id);
|
|
83
|
+
if (targets.length === 0) {
|
|
84
|
+
return { success: true, output: "No running jobs to await." };
|
|
85
|
+
}
|
|
86
|
+
const timeoutMs = typeof args.timeoutMs === "number" && args.timeoutMs > 0 ? args.timeoutMs : undefined;
|
|
87
|
+
const recs = await registry.awaitIds(targets, timeoutMs);
|
|
88
|
+
const stillRunning = recs.filter(r => r.status === "running").length;
|
|
89
|
+
const head = stillRunning > 0
|
|
90
|
+
? `Awaited ${recs.length} job(s); ${stillRunning} still running after the ${timeoutMs}ms timeout — await again or cancel.`
|
|
91
|
+
: `Awaited ${recs.length} job(s); all settled.`;
|
|
92
|
+
return { success: stillRunning === 0, output: `${head}\n\n${recs.map(rowLine).join("\n")}` };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (action === "cancel") {
|
|
96
|
+
const targets = ids.length ? ids : registry.running().map(r => r.id);
|
|
97
|
+
if (targets.length === 0) {
|
|
98
|
+
return { success: true, output: "No running jobs to cancel." };
|
|
99
|
+
}
|
|
100
|
+
const recs = registry.cancel(targets);
|
|
101
|
+
return { success: true, output: `Cancelled ${recs.length} job(s):\n${recs.map(rowLine).join("\n")}` };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { success: false, output: "", error: `Unknown job action '${action}'. Use start | list | tail | await | cancel.` };
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `lsp_rename` tool — cross-file TypeScript/JavaScript symbol rename (gjc `lsp`
|
|
3
|
+
* `action:"rename"` parity), using the same in-process `ts.LanguageService` as the
|
|
4
|
+
* read-only `lsp` tool. MUTATING: kept as its own tool (not an action on `lsp`) so
|
|
5
|
+
* the read-only-role toolset filter — which excludes tools by NAME — cannot be
|
|
6
|
+
* bypassed by an `apply:true` argument hidden inside a nominally "read-only" tool.
|
|
7
|
+
* Writes go through the existing `writeTool`, so the mutation lock and stale-read
|
|
8
|
+
* guard apply exactly as they do for `write`/`edit`/`ast_edit`.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "node:fs/promises";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import type * as ts from "typescript";
|
|
13
|
+
import type { ToolHandler } from "./engine";
|
|
14
|
+
import type { ToolResult } from "./tools";
|
|
15
|
+
import { writeTool } from "./tools";
|
|
16
|
+
import { languageServiceFor, parseSymbolSelector, resolvePosition, lineTextAt, lineOfPosition, formatLocation } from "./ts-language-service";
|
|
17
|
+
|
|
18
|
+
/** One-line protocol description appended to the launch system prompt. */
|
|
19
|
+
export const LSP_RENAME_TOOL_PROTOCOL_LINE =
|
|
20
|
+
`lsp_rename {file, line, symbol?, new_name, apply?} — rename a TypeScript/JavaScript symbol across every ` +
|
|
21
|
+
`file in the project via the same in-process language service as 'lsp' (same file/line/symbol addressing). ` +
|
|
22
|
+
`'apply' defaults to true (writes files — mutation lock/stale-read checks apply, same as write/edit); ` +
|
|
23
|
+
`apply:false previews every location that would change without writing anything.`;
|
|
24
|
+
|
|
25
|
+
function err(message: string): ToolResult {
|
|
26
|
+
return { success: false, output: "", error: message };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const IDENTIFIER_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
30
|
+
|
|
31
|
+
export function createLspRenameTool(): ToolHandler {
|
|
32
|
+
return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
|
|
33
|
+
const fileArg = typeof args.file === "string" ? args.file.trim() : "";
|
|
34
|
+
if (!fileArg) return err(`lsp_rename requires a non-empty "file".`);
|
|
35
|
+
|
|
36
|
+
const newName = typeof args.new_name === "string" ? args.new_name.trim()
|
|
37
|
+
: typeof args.newName === "string" ? args.newName.trim() : "";
|
|
38
|
+
if (!newName) return err(`lsp_rename requires a non-empty "new_name".`);
|
|
39
|
+
if (!IDENTIFIER_RE.test(newName)) return err(`"new_name" must be a valid identifier, got '${newName}'.`);
|
|
40
|
+
|
|
41
|
+
const absPath = path.resolve(cwd, fileArg);
|
|
42
|
+
let text: string;
|
|
43
|
+
try {
|
|
44
|
+
text = await fs.readFile(absPath, "utf-8");
|
|
45
|
+
} catch {
|
|
46
|
+
return err(`Cannot read file '${fileArg}'.`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const lineRaw = args.line;
|
|
50
|
+
const line = typeof lineRaw === "number" ? lineRaw : parseInt(String(lineRaw ?? ""), 10);
|
|
51
|
+
if (!Number.isFinite(line) || line < 1) return err(`lsp_rename requires a positive integer "line".`);
|
|
52
|
+
|
|
53
|
+
const symbol = parseSymbolSelector(typeof args.symbol === "string" ? args.symbol : undefined);
|
|
54
|
+
const pos = resolvePosition(text, line, symbol);
|
|
55
|
+
if ("error" in pos) return err(pos.error);
|
|
56
|
+
|
|
57
|
+
const service = await languageServiceFor(cwd, absPath);
|
|
58
|
+
const renameInfo = service.getRenameInfo(absPath, pos.position, {});
|
|
59
|
+
if (!renameInfo.canRename) {
|
|
60
|
+
const reason = "localizedErrorMessage" in renameInfo ? renameInfo.localizedErrorMessage : "not a renameable symbol here";
|
|
61
|
+
return err(`Cannot rename here: ${reason}.`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const locations = service.findRenameLocations(absPath, pos.position, false, false, {}) ?? [];
|
|
65
|
+
if (locations.length === 0) return { success: true, output: "No rename locations found." };
|
|
66
|
+
|
|
67
|
+
const apply = args.apply !== false; // default true
|
|
68
|
+
|
|
69
|
+
const byFile = new Map<string, ts.TextSpan[]>();
|
|
70
|
+
for (const loc of locations) {
|
|
71
|
+
const spans = byFile.get(loc.fileName) ?? [];
|
|
72
|
+
spans.push(loc.textSpan);
|
|
73
|
+
byFile.set(loc.fileName, spans);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const preview: string[] = [];
|
|
77
|
+
let totalEdits = 0;
|
|
78
|
+
for (const [fName, spans] of byFile) {
|
|
79
|
+
const rel = path.relative(cwd, fName) || fName;
|
|
80
|
+
let content: string;
|
|
81
|
+
try {
|
|
82
|
+
content = await fs.readFile(fName, "utf-8");
|
|
83
|
+
} catch {
|
|
84
|
+
return err(`Cannot read file '${rel}' while applying rename.`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const span of spans) {
|
|
88
|
+
totalEdits++;
|
|
89
|
+
const line1 = lineOfPosition(content, span.start);
|
|
90
|
+
preview.push(formatLocation(rel, line1, lineTextAt(content, line1)));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (apply) {
|
|
94
|
+
let newText = content;
|
|
95
|
+
for (const span of [...spans].sort((a, b) => b.start - a.start)) {
|
|
96
|
+
newText = newText.slice(0, span.start) + newName + newText.slice(span.start + span.length);
|
|
97
|
+
}
|
|
98
|
+
const res = await writeTool(rel, newText, cwd);
|
|
99
|
+
if (!res.success) return err(`${rel}: ${res.error ?? "write failed"}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const header = apply
|
|
104
|
+
? `Renamed ${totalEdits} occurrence(s) across ${byFile.size} file(s):`
|
|
105
|
+
: `Preview: ${totalEdits} occurrence(s) across ${byFile.size} file(s) would be renamed to '${newName}' (apply:false, nothing written):`;
|
|
106
|
+
return { success: true, output: `${header}\n${preview.join("\n")}` };
|
|
107
|
+
};
|
|
108
|
+
}
|