micode 0.7.0 → 0.7.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/package.json +7 -13
- package/src/agents/artifact-searcher.ts +46 -0
- package/src/agents/brainstormer.ts +145 -0
- package/src/agents/codebase-analyzer.ts +75 -0
- package/src/agents/codebase-locator.ts +71 -0
- package/src/agents/commander.ts +138 -0
- package/src/agents/executor.ts +215 -0
- package/src/agents/implementer.ts +99 -0
- package/src/agents/index.ts +44 -0
- package/src/agents/ledger-creator.ts +113 -0
- package/src/agents/pattern-finder.ts +70 -0
- package/src/agents/planner.ts +230 -0
- package/src/agents/project-initializer.ts +264 -0
- package/src/agents/reviewer.ts +102 -0
- package/src/config-loader.ts +89 -0
- package/src/hooks/artifact-auto-index.ts +111 -0
- package/src/hooks/auto-clear-ledger.ts +230 -0
- package/src/hooks/auto-compact.ts +241 -0
- package/src/hooks/comment-checker.ts +120 -0
- package/src/hooks/context-injector.ts +163 -0
- package/src/hooks/context-window-monitor.ts +106 -0
- package/src/hooks/file-ops-tracker.ts +96 -0
- package/src/hooks/ledger-loader.ts +78 -0
- package/src/hooks/preemptive-compaction.ts +183 -0
- package/src/hooks/session-recovery.ts +258 -0
- package/src/hooks/token-aware-truncation.ts +189 -0
- package/src/index.ts +258 -0
- package/src/tools/artifact-index/index.ts +269 -0
- package/src/tools/artifact-index/schema.sql +44 -0
- package/src/tools/artifact-search.ts +49 -0
- package/src/tools/ast-grep/index.ts +189 -0
- package/src/tools/background-task/manager.ts +397 -0
- package/src/tools/background-task/tools.ts +145 -0
- package/src/tools/background-task/types.ts +68 -0
- package/src/tools/btca/index.ts +82 -0
- package/src/tools/look-at.ts +210 -0
- package/src/tools/pty/buffer.ts +49 -0
- package/src/tools/pty/index.ts +34 -0
- package/src/tools/pty/manager.ts +159 -0
- package/src/tools/pty/tools/kill.ts +68 -0
- package/src/tools/pty/tools/list.ts +55 -0
- package/src/tools/pty/tools/read.ts +152 -0
- package/src/tools/pty/tools/spawn.ts +78 -0
- package/src/tools/pty/tools/write.ts +97 -0
- package/src/tools/pty/types.ts +62 -0
- package/src/utils/model-limits.ts +36 -0
- package/dist/agents/artifact-searcher.d.ts +0 -2
- package/dist/agents/brainstormer.d.ts +0 -2
- package/dist/agents/codebase-analyzer.d.ts +0 -2
- package/dist/agents/codebase-locator.d.ts +0 -2
- package/dist/agents/commander.d.ts +0 -3
- package/dist/agents/executor.d.ts +0 -2
- package/dist/agents/implementer.d.ts +0 -2
- package/dist/agents/index.d.ts +0 -15
- package/dist/agents/ledger-creator.d.ts +0 -2
- package/dist/agents/pattern-finder.d.ts +0 -2
- package/dist/agents/planner.d.ts +0 -2
- package/dist/agents/project-initializer.d.ts +0 -2
- package/dist/agents/reviewer.d.ts +0 -2
- package/dist/config-loader.d.ts +0 -20
- package/dist/hooks/artifact-auto-index.d.ts +0 -19
- package/dist/hooks/auto-clear-ledger.d.ts +0 -11
- package/dist/hooks/auto-compact.d.ts +0 -9
- package/dist/hooks/comment-checker.d.ts +0 -9
- package/dist/hooks/context-injector.d.ts +0 -15
- package/dist/hooks/context-window-monitor.d.ts +0 -15
- package/dist/hooks/file-ops-tracker.d.ts +0 -26
- package/dist/hooks/ledger-loader.d.ts +0 -16
- package/dist/hooks/preemptive-compaction.d.ts +0 -9
- package/dist/hooks/session-recovery.d.ts +0 -9
- package/dist/hooks/token-aware-truncation.d.ts +0 -15
- package/dist/index.d.ts +0 -3
- package/dist/index.js +0 -17089
- package/dist/tools/artifact-index/index.d.ts +0 -38
- package/dist/tools/artifact-search.d.ts +0 -17
- package/dist/tools/ast-grep/index.d.ts +0 -88
- package/dist/tools/background-task/manager.d.ts +0 -27
- package/dist/tools/background-task/tools.d.ts +0 -41
- package/dist/tools/background-task/types.d.ts +0 -53
- package/dist/tools/btca/index.d.ts +0 -19
- package/dist/tools/look-at.d.ts +0 -11
- package/dist/tools/pty/buffer.d.ts +0 -11
- package/dist/tools/pty/index.d.ts +0 -74
- package/dist/tools/pty/manager.d.ts +0 -14
- package/dist/tools/pty/tools/kill.d.ts +0 -12
- package/dist/tools/pty/tools/list.d.ts +0 -6
- package/dist/tools/pty/tools/read.d.ts +0 -18
- package/dist/tools/pty/tools/spawn.d.ts +0 -20
- package/dist/tools/pty/tools/write.d.ts +0 -12
- package/dist/tools/pty/types.d.ts +0 -54
- package/dist/utils/model-limits.d.ts +0 -7
- /package/{dist/tools/background-task/index.d.ts → src/tools/background-task/index.ts} +0 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { spawn, which } from "bun";
|
|
2
|
+
import { tool } from "@opencode-ai/plugin/tool";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Check if ast-grep CLI (sg) is available on the system.
|
|
6
|
+
* Returns installation instructions if not found.
|
|
7
|
+
*/
|
|
8
|
+
export async function checkAstGrepAvailable(): Promise<{ available: boolean; message?: string }> {
|
|
9
|
+
const sgPath = which("sg");
|
|
10
|
+
if (sgPath) {
|
|
11
|
+
return { available: true };
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
available: false,
|
|
15
|
+
message:
|
|
16
|
+
"ast-grep CLI (sg) not found. AST-aware search/replace will not work.\n" +
|
|
17
|
+
"Install with one of:\n" +
|
|
18
|
+
" npm install -g @ast-grep/cli\n" +
|
|
19
|
+
" cargo install ast-grep --locked\n" +
|
|
20
|
+
" brew install ast-grep",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const LANGUAGES = [
|
|
25
|
+
"c",
|
|
26
|
+
"cpp",
|
|
27
|
+
"csharp",
|
|
28
|
+
"css",
|
|
29
|
+
"dart",
|
|
30
|
+
"elixir",
|
|
31
|
+
"go",
|
|
32
|
+
"haskell",
|
|
33
|
+
"html",
|
|
34
|
+
"java",
|
|
35
|
+
"javascript",
|
|
36
|
+
"json",
|
|
37
|
+
"kotlin",
|
|
38
|
+
"lua",
|
|
39
|
+
"php",
|
|
40
|
+
"python",
|
|
41
|
+
"ruby",
|
|
42
|
+
"rust",
|
|
43
|
+
"scala",
|
|
44
|
+
"sql",
|
|
45
|
+
"swift",
|
|
46
|
+
"tsx",
|
|
47
|
+
"typescript",
|
|
48
|
+
"yaml",
|
|
49
|
+
] as const;
|
|
50
|
+
|
|
51
|
+
interface Match {
|
|
52
|
+
file: string;
|
|
53
|
+
range: { start: { line: number; column: number }; end: { line: number; column: number } };
|
|
54
|
+
text: string;
|
|
55
|
+
replacement?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function runSg(args: string[]): Promise<{ matches: Match[]; error?: string }> {
|
|
59
|
+
try {
|
|
60
|
+
const proc = spawn(["sg", ...args], {
|
|
61
|
+
stdout: "pipe",
|
|
62
|
+
stderr: "pipe",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
66
|
+
new Response(proc.stdout).text(),
|
|
67
|
+
new Response(proc.stderr).text(),
|
|
68
|
+
proc.exited,
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
if (exitCode !== 0 && !stdout.trim()) {
|
|
72
|
+
if (stderr.includes("No files found")) {
|
|
73
|
+
return { matches: [] };
|
|
74
|
+
}
|
|
75
|
+
return { matches: [], error: stderr.trim() || `Exit code ${exitCode}` };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!stdout.trim()) return { matches: [] };
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const matches = JSON.parse(stdout) as Match[];
|
|
82
|
+
return { matches };
|
|
83
|
+
} catch {
|
|
84
|
+
return { matches: [], error: "Failed to parse output" };
|
|
85
|
+
}
|
|
86
|
+
} catch (e) {
|
|
87
|
+
const err = e as Error;
|
|
88
|
+
if (err.message?.includes("ENOENT")) {
|
|
89
|
+
return {
|
|
90
|
+
matches: [],
|
|
91
|
+
error:
|
|
92
|
+
"ast-grep CLI not found. Install with:\n" +
|
|
93
|
+
" npm install -g @ast-grep/cli\n" +
|
|
94
|
+
" cargo install ast-grep --locked\n" +
|
|
95
|
+
" brew install ast-grep",
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return { matches: [], error: err.message };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function formatMatches(matches: Match[], isDryRun = false): string {
|
|
103
|
+
if (matches.length === 0) return "No matches found";
|
|
104
|
+
|
|
105
|
+
const MAX = 100;
|
|
106
|
+
const truncated = matches.length > MAX;
|
|
107
|
+
const shown = matches.slice(0, MAX);
|
|
108
|
+
|
|
109
|
+
const lines = shown.map((m) => {
|
|
110
|
+
const loc = `${m.file}:${m.range.start.line}:${m.range.start.column}`;
|
|
111
|
+
const text = m.text.length > 100 ? `${m.text.slice(0, 100)}...` : m.text;
|
|
112
|
+
if (isDryRun && m.replacement) {
|
|
113
|
+
return `${loc}\n - ${text}\n + ${m.replacement}`;
|
|
114
|
+
}
|
|
115
|
+
return `${loc}: ${text}`;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (truncated) {
|
|
119
|
+
lines.unshift(`Found ${matches.length} matches (showing first ${MAX}):`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return lines.join("\n");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const ast_grep_search = tool({
|
|
126
|
+
description:
|
|
127
|
+
"Search code patterns using AST-aware matching. " +
|
|
128
|
+
"Use meta-variables: $VAR (single node), $$$ (multiple nodes). " +
|
|
129
|
+
"Patterns must be complete AST nodes. " +
|
|
130
|
+
"Examples: 'console.log($MSG)', 'def $FUNC($$$):', 'async function $NAME($$$)'",
|
|
131
|
+
args: {
|
|
132
|
+
pattern: tool.schema.string().describe("AST pattern with meta-variables"),
|
|
133
|
+
lang: tool.schema.enum(LANGUAGES).describe("Target language"),
|
|
134
|
+
paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search"),
|
|
135
|
+
},
|
|
136
|
+
execute: async (args) => {
|
|
137
|
+
const sgArgs = ["run", "-p", args.pattern, "--lang", args.lang, "--json=compact"];
|
|
138
|
+
if (args.paths?.length) {
|
|
139
|
+
sgArgs.push(...args.paths);
|
|
140
|
+
} else {
|
|
141
|
+
sgArgs.push(".");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const result = await runSg(sgArgs);
|
|
145
|
+
if (result.error) return `Error: ${result.error}`;
|
|
146
|
+
return formatMatches(result.matches);
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
export const ast_grep_replace = tool({
|
|
151
|
+
description:
|
|
152
|
+
"Replace code patterns with AST-aware rewriting. " +
|
|
153
|
+
"Dry-run by default. Use meta-variables in rewrite to preserve matched content. " +
|
|
154
|
+
"Example: pattern='console.log($MSG)' rewrite='logger.info($MSG)'",
|
|
155
|
+
args: {
|
|
156
|
+
pattern: tool.schema.string().describe("AST pattern to match"),
|
|
157
|
+
rewrite: tool.schema.string().describe("Replacement pattern"),
|
|
158
|
+
lang: tool.schema.enum(LANGUAGES).describe("Target language"),
|
|
159
|
+
paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search"),
|
|
160
|
+
apply: tool.schema.boolean().optional().describe("Apply changes (default: false, dry-run)"),
|
|
161
|
+
},
|
|
162
|
+
execute: async (args) => {
|
|
163
|
+
const sgArgs = ["run", "-p", args.pattern, "-r", args.rewrite, "--lang", args.lang, "--json=compact"];
|
|
164
|
+
|
|
165
|
+
if (args.apply) {
|
|
166
|
+
sgArgs.push("--update-all");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (args.paths?.length) {
|
|
170
|
+
sgArgs.push(...args.paths);
|
|
171
|
+
} else {
|
|
172
|
+
sgArgs.push(".");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const result = await runSg(sgArgs);
|
|
176
|
+
if (result.error) return `Error: ${result.error}`;
|
|
177
|
+
|
|
178
|
+
const isDryRun = !args.apply;
|
|
179
|
+
const output = formatMatches(result.matches, isDryRun);
|
|
180
|
+
|
|
181
|
+
if (isDryRun && result.matches.length > 0) {
|
|
182
|
+
return `${output}\n\n(Dry run - use apply=true to apply changes)`;
|
|
183
|
+
}
|
|
184
|
+
if (args.apply && result.matches.length > 0) {
|
|
185
|
+
return `Applied ${result.matches.length} replacements:\n${output}`;
|
|
186
|
+
}
|
|
187
|
+
return output;
|
|
188
|
+
},
|
|
189
|
+
});
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
2
|
+
import type {
|
|
3
|
+
BackgroundTask,
|
|
4
|
+
BackgroundTaskInput,
|
|
5
|
+
SessionCreateResponse,
|
|
6
|
+
SessionStatusResponse,
|
|
7
|
+
SessionMessagesResponse,
|
|
8
|
+
} from "./types";
|
|
9
|
+
|
|
10
|
+
const POLL_INTERVAL_MS = 2000;
|
|
11
|
+
const TASK_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
12
|
+
|
|
13
|
+
function generateTaskId(): string {
|
|
14
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
15
|
+
let result = "bg_";
|
|
16
|
+
for (let i = 0; i < 8; i++) {
|
|
17
|
+
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatDuration(start: Date, end?: Date): string {
|
|
23
|
+
const ms = (end || new Date()).getTime() - start.getTime();
|
|
24
|
+
const seconds = Math.floor(ms / 1000);
|
|
25
|
+
const minutes = Math.floor(seconds / 60);
|
|
26
|
+
|
|
27
|
+
if (minutes > 0) {
|
|
28
|
+
return `${minutes}m ${seconds % 60}s`;
|
|
29
|
+
}
|
|
30
|
+
return `${seconds}s`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class BackgroundTaskManager {
|
|
34
|
+
private tasks: Map<string, BackgroundTask> = new Map();
|
|
35
|
+
private notifications: Map<string, BackgroundTask[]> = new Map();
|
|
36
|
+
private pollingInterval?: ReturnType<typeof setInterval>;
|
|
37
|
+
private ctx: PluginInput;
|
|
38
|
+
|
|
39
|
+
constructor(ctx: PluginInput) {
|
|
40
|
+
this.ctx = ctx;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async launch(input: BackgroundTaskInput): Promise<BackgroundTask> {
|
|
44
|
+
const taskId = generateTaskId();
|
|
45
|
+
|
|
46
|
+
// Create new session for background task
|
|
47
|
+
const sessionResp = await this.ctx.client.session.create({
|
|
48
|
+
body: {},
|
|
49
|
+
query: { directory: this.ctx.directory },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const sessionData = sessionResp as SessionCreateResponse;
|
|
53
|
+
const sessionID = sessionData.data?.id;
|
|
54
|
+
|
|
55
|
+
if (!sessionID) {
|
|
56
|
+
throw new Error("Failed to create background session");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const task: BackgroundTask = {
|
|
60
|
+
id: taskId,
|
|
61
|
+
sessionID,
|
|
62
|
+
parentSessionID: input.parentSessionID,
|
|
63
|
+
parentMessageID: input.parentMessageID,
|
|
64
|
+
description: input.description,
|
|
65
|
+
prompt: input.prompt,
|
|
66
|
+
agent: input.agent,
|
|
67
|
+
status: "running",
|
|
68
|
+
startedAt: new Date(),
|
|
69
|
+
progress: {
|
|
70
|
+
toolCalls: 0,
|
|
71
|
+
lastUpdate: new Date(),
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
this.tasks.set(taskId, task);
|
|
76
|
+
|
|
77
|
+
// Fire-and-forget prompt
|
|
78
|
+
this.ctx.client.session
|
|
79
|
+
.prompt({
|
|
80
|
+
path: { id: sessionID },
|
|
81
|
+
body: {
|
|
82
|
+
parts: [{ type: "text", text: input.prompt }],
|
|
83
|
+
agent: input.agent,
|
|
84
|
+
},
|
|
85
|
+
query: { directory: this.ctx.directory },
|
|
86
|
+
})
|
|
87
|
+
.catch((error) => {
|
|
88
|
+
console.error(`[background-task] Failed to prompt session ${sessionID}:`, error);
|
|
89
|
+
task.status = "error";
|
|
90
|
+
task.error = error instanceof Error ? error.message : String(error);
|
|
91
|
+
task.completedAt = new Date();
|
|
92
|
+
this.markForNotification(task);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Start polling if not already
|
|
96
|
+
this.startPolling();
|
|
97
|
+
|
|
98
|
+
return task;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async cancel(taskId: string): Promise<boolean> {
|
|
102
|
+
const task = this.tasks.get(taskId);
|
|
103
|
+
if (!task || task.status !== "running") {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
// Fire-and-forget abort
|
|
109
|
+
this.ctx.client.session
|
|
110
|
+
.abort({
|
|
111
|
+
path: { id: task.sessionID },
|
|
112
|
+
query: { directory: this.ctx.directory },
|
|
113
|
+
})
|
|
114
|
+
.catch((error) => {
|
|
115
|
+
console.error(`[background-task] Failed to abort session ${task.sessionID}:`, error);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
task.status = "cancelled";
|
|
119
|
+
task.completedAt = new Date();
|
|
120
|
+
this.markForNotification(task);
|
|
121
|
+
return true;
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async cancelAll(): Promise<number> {
|
|
128
|
+
let cancelled = 0;
|
|
129
|
+
for (const task of this.tasks.values()) {
|
|
130
|
+
if (task.status === "running") {
|
|
131
|
+
if (await this.cancel(task.id)) {
|
|
132
|
+
cancelled++;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return cancelled;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
getTask(taskId: string): BackgroundTask | undefined {
|
|
140
|
+
return this.tasks.get(taskId);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
getAllTasks(): BackgroundTask[] {
|
|
144
|
+
return Array.from(this.tasks.values());
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Poll all running tasks and update their status.
|
|
149
|
+
* Called by background_list to ensure fresh status.
|
|
150
|
+
*/
|
|
151
|
+
async refreshTaskStatus(): Promise<void> {
|
|
152
|
+
const runningTasks = this.getRunningTasks();
|
|
153
|
+
if (runningTasks.length === 0) return;
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
// Get all session statuses in one call
|
|
157
|
+
const resp = await this.ctx.client.session.status({
|
|
158
|
+
query: { directory: this.ctx.directory },
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const statusResp = resp as SessionStatusResponse;
|
|
162
|
+
const statusMap = statusResp.data || {};
|
|
163
|
+
|
|
164
|
+
for (const task of runningTasks) {
|
|
165
|
+
const sessionStatus = statusMap[task.sessionID];
|
|
166
|
+
const statusType = sessionStatus?.type;
|
|
167
|
+
|
|
168
|
+
// Store last known session status for debugging
|
|
169
|
+
(task as BackgroundTask & { _sessionStatus?: string })._sessionStatus = statusType;
|
|
170
|
+
|
|
171
|
+
if (statusType === "idle" || statusType === undefined) {
|
|
172
|
+
// Session is idle OR not in status map (likely finished and cleaned up)
|
|
173
|
+
// Try to get result - if successful, mark as completed
|
|
174
|
+
const result = await this.fetchTaskResult(task);
|
|
175
|
+
if (result !== undefined || statusType === "idle") {
|
|
176
|
+
task.status = "completed";
|
|
177
|
+
task.completedAt = new Date();
|
|
178
|
+
task.result = result;
|
|
179
|
+
}
|
|
180
|
+
// If result is undefined and statusType is undefined, keep waiting
|
|
181
|
+
// (might be a timing issue with status propagation)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} catch (error) {
|
|
185
|
+
console.error("[background-task] Failed to refresh task status:", error);
|
|
186
|
+
// Don't mark all tasks as error - they may still be running
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
getRunningTasks(): BackgroundTask[] {
|
|
191
|
+
return this.getAllTasks().filter((t) => t.status === "running");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async getTaskResult(taskId: string): Promise<string | undefined> {
|
|
195
|
+
const task = this.tasks.get(taskId);
|
|
196
|
+
if (!task || task.status === "running") {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (task.result) {
|
|
201
|
+
return task.result;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const result = await this.fetchTaskResult(task);
|
|
205
|
+
if (result !== undefined) {
|
|
206
|
+
task.result = result;
|
|
207
|
+
}
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Fetch result from session messages without checking task status.
|
|
213
|
+
* Used during polling to check if a session has completed.
|
|
214
|
+
*/
|
|
215
|
+
private async fetchTaskResult(task: BackgroundTask): Promise<string | undefined> {
|
|
216
|
+
try {
|
|
217
|
+
const resp = await this.ctx.client.session.messages({
|
|
218
|
+
path: { id: task.sessionID },
|
|
219
|
+
query: { directory: this.ctx.directory },
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const messagesResp = resp as SessionMessagesResponse;
|
|
223
|
+
const messages = messagesResp.data || [];
|
|
224
|
+
const lastAssistant = [...messages].reverse().find((m) => m.info?.role === "assistant");
|
|
225
|
+
|
|
226
|
+
if (lastAssistant) {
|
|
227
|
+
const textParts = lastAssistant.parts?.filter((p) => p.type === "text") || [];
|
|
228
|
+
return textParts.map((p) => p.text || "").join("\n");
|
|
229
|
+
}
|
|
230
|
+
} catch (error) {
|
|
231
|
+
console.error(`[background-task] Failed to fetch result for task ${task.id}:`, error);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
formatTaskStatus(task: BackgroundTask): string {
|
|
238
|
+
const duration = formatDuration(task.startedAt, task.completedAt);
|
|
239
|
+
const status = task.status.toUpperCase();
|
|
240
|
+
|
|
241
|
+
let output = `## Task: ${task.description}\n\n`;
|
|
242
|
+
output += `| Field | Value |\n|-------|-------|\n`;
|
|
243
|
+
output += `| ID | ${task.id} |\n`;
|
|
244
|
+
output += `| Status | ${status} |\n`;
|
|
245
|
+
output += `| Agent | ${task.agent} |\n`;
|
|
246
|
+
output += `| Duration | ${duration} |\n`;
|
|
247
|
+
|
|
248
|
+
if (task.progress) {
|
|
249
|
+
output += `| Tool Calls | ${task.progress.toolCalls} |\n`;
|
|
250
|
+
if (task.progress.lastTool) {
|
|
251
|
+
output += `| Last Tool | ${task.progress.lastTool} |\n`;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (task.error) {
|
|
256
|
+
output += `\n### Error\n${task.error}\n`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return output;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private startPolling(): void {
|
|
263
|
+
if (this.pollingInterval) return;
|
|
264
|
+
|
|
265
|
+
this.pollingInterval = setInterval(() => {
|
|
266
|
+
this.pollRunningTasks();
|
|
267
|
+
}, POLL_INTERVAL_MS);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private stopPolling(): void {
|
|
271
|
+
if (this.pollingInterval) {
|
|
272
|
+
clearInterval(this.pollingInterval);
|
|
273
|
+
this.pollingInterval = undefined;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private cleanupOldTasks(): void {
|
|
278
|
+
const now = Date.now();
|
|
279
|
+
for (const [taskId, task] of this.tasks) {
|
|
280
|
+
// Only cleanup completed/cancelled/error tasks
|
|
281
|
+
if (task.status === "running") continue;
|
|
282
|
+
|
|
283
|
+
const completedAt = task.completedAt?.getTime() || 0;
|
|
284
|
+
if (now - completedAt > TASK_TTL_MS) {
|
|
285
|
+
this.tasks.delete(taskId);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private async pollRunningTasks(): Promise<void> {
|
|
291
|
+
// Cleanup old completed tasks to prevent memory leak
|
|
292
|
+
this.cleanupOldTasks();
|
|
293
|
+
|
|
294
|
+
const runningTasks = this.getRunningTasks();
|
|
295
|
+
|
|
296
|
+
if (runningTasks.length === 0) {
|
|
297
|
+
this.stopPolling();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
// Get all session statuses in one call
|
|
303
|
+
const resp = await this.ctx.client.session.status({
|
|
304
|
+
query: { directory: this.ctx.directory },
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const statusResp = resp as SessionStatusResponse;
|
|
308
|
+
const statusMap = statusResp.data || {};
|
|
309
|
+
|
|
310
|
+
for (const task of runningTasks) {
|
|
311
|
+
const sessionStatus = statusMap[task.sessionID];
|
|
312
|
+
const statusType = sessionStatus?.type;
|
|
313
|
+
|
|
314
|
+
console.log(`[background-task] Poll ${task.id}: session=${task.sessionID} status=${statusType}`);
|
|
315
|
+
|
|
316
|
+
if (statusType === "idle" || statusType === undefined) {
|
|
317
|
+
// Session is idle OR not in status map (likely finished and cleaned up)
|
|
318
|
+
// Try to get result - if successful, mark as completed
|
|
319
|
+
const result = await this.fetchTaskResult(task);
|
|
320
|
+
if (result !== undefined || statusType === "idle") {
|
|
321
|
+
task.status = "completed";
|
|
322
|
+
task.completedAt = new Date();
|
|
323
|
+
task.result = result;
|
|
324
|
+
this.markForNotification(task);
|
|
325
|
+
|
|
326
|
+
await this.ctx.client.tui
|
|
327
|
+
.showToast({
|
|
328
|
+
body: {
|
|
329
|
+
title: "Background Task Complete",
|
|
330
|
+
message: task.description,
|
|
331
|
+
variant: "success",
|
|
332
|
+
duration: 5000,
|
|
333
|
+
},
|
|
334
|
+
})
|
|
335
|
+
.catch((error) => {
|
|
336
|
+
console.error(`[background-task] Failed to show toast for task ${task.id}:`, error);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
// If result is undefined and statusType is undefined, keep waiting
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
} catch (error) {
|
|
343
|
+
console.error("[background-task] Failed to poll tasks:", error);
|
|
344
|
+
// Don't mark tasks as error - they may still be running, just can't check
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private markForNotification(task: BackgroundTask): void {
|
|
349
|
+
const existing = this.notifications.get(task.parentSessionID) || [];
|
|
350
|
+
existing.push(task);
|
|
351
|
+
this.notifications.set(task.parentSessionID, existing);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
getPendingNotifications(parentSessionID: string): BackgroundTask[] {
|
|
355
|
+
const notifications = this.notifications.get(parentSessionID) || [];
|
|
356
|
+
this.notifications.delete(parentSessionID);
|
|
357
|
+
return notifications;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
handleEvent(event: { type: string; properties?: unknown }): void {
|
|
361
|
+
const props = event.properties as Record<string, unknown> | undefined;
|
|
362
|
+
|
|
363
|
+
// Track tool usage for progress
|
|
364
|
+
if (event.type === "message.part.updated") {
|
|
365
|
+
const info = props?.info as Record<string, unknown> | undefined;
|
|
366
|
+
const sessionID = info?.sessionID as string | undefined;
|
|
367
|
+
const partType = info?.type as string | undefined;
|
|
368
|
+
|
|
369
|
+
if (sessionID && partType === "tool_use") {
|
|
370
|
+
for (const task of this.tasks.values()) {
|
|
371
|
+
if (task.sessionID === sessionID && task.status === "running") {
|
|
372
|
+
if (!task.progress) {
|
|
373
|
+
task.progress = { toolCalls: 0, lastUpdate: new Date() };
|
|
374
|
+
}
|
|
375
|
+
task.progress.toolCalls++;
|
|
376
|
+
task.progress.lastTool = (info?.name as string) || undefined;
|
|
377
|
+
task.progress.lastUpdate = new Date();
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Cleanup on session delete
|
|
385
|
+
if (event.type === "session.deleted") {
|
|
386
|
+
const sessionInfo = props?.info as { id?: string } | undefined;
|
|
387
|
+
if (sessionInfo?.id) {
|
|
388
|
+
for (const task of this.tasks.values()) {
|
|
389
|
+
if (task.sessionID === sessionInfo.id && task.status === "running") {
|
|
390
|
+
task.status = "cancelled";
|
|
391
|
+
task.completedAt = new Date();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|