one-code-extension 0.1.0
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/LICENSE +32 -0
- package/README.md +202 -0
- package/agents/explore.md +24 -0
- package/agents/general-purpose.md +21 -0
- package/agents/plan.md +22 -0
- package/docs/decisions/auto-mode.md +526 -0
- package/docs/decisions/branding.md +127 -0
- package/docs/decisions/code-review-remediation.md +106 -0
- package/docs/decisions/compaction.md +140 -0
- package/docs/decisions/distribution.md +133 -0
- package/docs/decisions/hooks.md +64 -0
- package/docs/decisions/lsp.md +58 -0
- package/docs/decisions/mcp.md +104 -0
- package/docs/decisions/memory-state.md +170 -0
- package/docs/decisions/model-policy.md +126 -0
- package/docs/decisions/modes.md +178 -0
- package/docs/decisions/skills-plugins.md +56 -0
- package/docs/decisions/subagents-workflows.md +225 -0
- package/docs/decisions/system-prompt.md +98 -0
- package/docs/decisions/tools.md +407 -0
- package/docs/decisions/tui.md +202 -0
- package/docs/decisions.md +148 -0
- package/extensions/ask-user/index.ts +124 -0
- package/extensions/ask-user/questions.ts +60 -0
- package/extensions/auto-mode/actions.ts +78 -0
- package/extensions/auto-mode/classifier.ts +388 -0
- package/extensions/auto-mode/config.ts +237 -0
- package/extensions/auto-mode/decision-log.ts +60 -0
- package/extensions/auto-mode/defaults.ts +65 -0
- package/extensions/auto-mode/instructions.ts +74 -0
- package/extensions/auto-mode/model-picker.ts +225 -0
- package/extensions/auto-mode/model-select.ts +178 -0
- package/extensions/auto-mode/paths.ts +109 -0
- package/extensions/auto-mode/pause.ts +96 -0
- package/extensions/auto-mode/prompt.ts +307 -0
- package/extensions/auto-mode/rules.ts +96 -0
- package/extensions/auto-mode/safety-floor.ts +100 -0
- package/extensions/auto-mode/sensitive.ts +137 -0
- package/extensions/auto-mode/shell-analysis.ts +702 -0
- package/extensions/background/index.ts +405 -0
- package/extensions/background/registry.ts +81 -0
- package/extensions/background/wakeup.ts +41 -0
- package/extensions/bash/background.ts +136 -0
- package/extensions/bash/index.ts +149 -0
- package/extensions/branding/assistant-marker.ts +53 -0
- package/extensions/branding/index.ts +366 -0
- package/extensions/branding/prompt-editor.ts +42 -0
- package/extensions/branding/prompt-marker.ts +29 -0
- package/extensions/branding/startup.ts +152 -0
- package/extensions/claude-compat/index.ts +50 -0
- package/extensions/claude-context/index.ts +79 -0
- package/extensions/compaction/index.ts +231 -0
- package/extensions/compaction/prompt.ts +191 -0
- package/extensions/context-management/index.ts +147 -0
- package/extensions/effort/index.ts +216 -0
- package/extensions/effort/slider.ts +253 -0
- package/extensions/exit/index.ts +28 -0
- package/extensions/file-tracker/index.ts +103 -0
- package/extensions/file-tracker/tracker.ts +145 -0
- package/extensions/hooks/debug.ts +53 -0
- package/extensions/hooks/executor.ts +132 -0
- package/extensions/hooks/index.ts +310 -0
- package/extensions/hooks/matcher.ts +72 -0
- package/extensions/hooks/plugin-hooks.ts +79 -0
- package/extensions/hooks/protocol.ts +151 -0
- package/extensions/hooks/settings.ts +163 -0
- package/extensions/hooks/trust.ts +151 -0
- package/extensions/lib/branch-restore.ts +35 -0
- package/extensions/lib/claude-context.ts +148 -0
- package/extensions/lib/deferred.ts +119 -0
- package/extensions/lib/git.ts +14 -0
- package/extensions/lib/memory.ts +219 -0
- package/extensions/lib/model-policy.ts +436 -0
- package/extensions/lib/model-tier.ts +99 -0
- package/extensions/lib/notifications.ts +18 -0
- package/extensions/lib/paths.ts +26 -0
- package/extensions/lib/per-cwd.ts +21 -0
- package/extensions/lib/persisted-output.ts +70 -0
- package/extensions/lib/pi-version.ts +54 -0
- package/extensions/lib/plugins.ts +227 -0
- package/extensions/lib/reminders.ts +212 -0
- package/extensions/lib/scratchpad.ts +68 -0
- package/extensions/lib/tui-render.ts +522 -0
- package/extensions/lsp/client.ts +278 -0
- package/extensions/lsp/format.ts +46 -0
- package/extensions/lsp/index.ts +193 -0
- package/extensions/lsp/protocol.ts +64 -0
- package/extensions/lsp/servers.ts +79 -0
- package/extensions/mcp/client.ts +205 -0
- package/extensions/mcp/config.ts +199 -0
- package/extensions/mcp/index.ts +373 -0
- package/extensions/mcp/schema.ts +161 -0
- package/extensions/memory/index.ts +90 -0
- package/extensions/notebook/index.ts +77 -0
- package/extensions/notebook/notebook.ts +108 -0
- package/extensions/permissions/index.ts +876 -0
- package/extensions/permissions/matcher.ts +455 -0
- package/extensions/permissions/modes.ts +107 -0
- package/extensions/permissions/protected-paths.ts +125 -0
- package/extensions/permissions/settings.ts +98 -0
- package/extensions/plan-mode/index.ts +209 -0
- package/extensions/plan-mode/reminder.ts +33 -0
- package/extensions/plan-mode/slug.ts +28 -0
- package/extensions/plan-mode/viewer.ts +124 -0
- package/extensions/plugins/index.ts +115 -0
- package/extensions/plugins/template.ts +42 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +24 -0
- package/extensions/skill/index.ts +170 -0
- package/extensions/subagents/agents.ts +126 -0
- package/extensions/subagents/child.ts +414 -0
- package/extensions/subagents/default-model.ts +122 -0
- package/extensions/subagents/index.ts +1119 -0
- package/extensions/subagents/model-select.ts +346 -0
- package/extensions/subagents/rpc-turns.ts +123 -0
- package/extensions/subagents/runs.ts +93 -0
- package/extensions/subagents/usage.ts +53 -0
- package/extensions/subagents/worktree.ts +69 -0
- package/extensions/system-prompt/environment.ts +35 -0
- package/extensions/system-prompt/index.ts +54 -0
- package/extensions/system-prompt/template.ts +103 -0
- package/extensions/system-prompt/tiers/common.ts +63 -0
- package/extensions/system-prompt/tiers/frontier.ts +24 -0
- package/extensions/system-prompt/tiers/low.ts +71 -0
- package/extensions/system-prompt/tiers/mid.ts +56 -0
- package/extensions/system-reminder/index.ts +37 -0
- package/extensions/tasks/index.ts +140 -0
- package/extensions/tasks/store.ts +174 -0
- package/extensions/todo/index.ts +133 -0
- package/extensions/tool-search/index.ts +168 -0
- package/extensions/tool-style/index.ts +84 -0
- package/extensions/web/index.ts +43 -0
- package/extensions/web-fetch/extract.ts +139 -0
- package/extensions/web-fetch/index.ts +291 -0
- package/extensions/web-fetch/summarize.ts +78 -0
- package/extensions/workflow/agent-session.ts +368 -0
- package/extensions/workflow/globals.ts +251 -0
- package/extensions/workflow/index.ts +364 -0
- package/extensions/workflow/journal.ts +75 -0
- package/extensions/workflow/permission-gate.ts +71 -0
- package/extensions/workflow/run-manager.ts +306 -0
- package/extensions/workflow/saved-workflows.ts +45 -0
- package/extensions/workflow/script-source.ts +201 -0
- package/extensions/workflow/types.ts +99 -0
- package/extensions/workflow/vm-runtime.ts +31 -0
- package/extensions/workflow/widget.ts +56 -0
- package/extensions/worktree/index.ts +266 -0
- package/extensions/worktree/rewrite.ts +59 -0
- package/package.json +101 -0
- package/themes/one-code-light.json +76 -0
- package/themes/one-code.json +76 -0
- package/types/app.d.ts +5 -0
- package/types/community-packages.d.ts +12 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* background extension — Claude Code's background-task surface:
|
|
3
|
+
* monitor, task_output, task_stop, schedule_wakeup.
|
|
4
|
+
*
|
|
5
|
+
* Owns the BackgroundRegistry. Other extensions (subagents) register their
|
|
6
|
+
* long-running work over TASK_REGISTER_CHANNEL at runtime, so task_output and
|
|
7
|
+
* task_stop address every background task in the session regardless of which
|
|
8
|
+
* extension started it. Events and completions are delivered as follow-up
|
|
9
|
+
* messages framed as system notifications, never as user input.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { Type } from "typebox";
|
|
15
|
+
import { DEFER_CHANNEL } from "../lib/deferred.ts";
|
|
16
|
+
import { ccToolRenderers, customMessageText, notificationComponent } from "../lib/tui-render.ts";
|
|
17
|
+
import {
|
|
18
|
+
type BackgroundTask,
|
|
19
|
+
BackgroundRegistry,
|
|
20
|
+
formatTaskLine,
|
|
21
|
+
generateTaskId,
|
|
22
|
+
TASK_REGISTER_CHANNEL,
|
|
23
|
+
} from "./registry.ts";
|
|
24
|
+
import { buildWakeupMessage, clampDelaySeconds, describeSchedule } from "./wakeup.ts";
|
|
25
|
+
import { systemNotification } from "../lib/notifications.ts";
|
|
26
|
+
|
|
27
|
+
const OUTPUT_CAP = 30_000;
|
|
28
|
+
const STORED_OUTPUT_CAP = 200_000;
|
|
29
|
+
const EVENT_BATCH_MS = 1000;
|
|
30
|
+
const DEFAULT_MONITOR_TIMEOUT_MS = 300_000;
|
|
31
|
+
const MAX_MONITOR_TIMEOUT_MS = 3_600_000;
|
|
32
|
+
const MAX_BLOCK_TIMEOUT_MS = 600_000;
|
|
33
|
+
|
|
34
|
+
function tail(text: string, cap: number): string {
|
|
35
|
+
return text.length <= cap ? text : `… (earlier output truncated)\n${text.slice(-cap)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default function backgroundExtension(pi: ExtensionAPI) {
|
|
39
|
+
const registry = new BackgroundRegistry();
|
|
40
|
+
let lastCtx: ExtensionContext | undefined;
|
|
41
|
+
let wakeup: { timer: NodeJS.Timeout; prompt: string; reason: string } | undefined;
|
|
42
|
+
|
|
43
|
+
pi.events.on(TASK_REGISTER_CHANNEL, (task) => registry.register(task as BackgroundTask));
|
|
44
|
+
|
|
45
|
+
const updateWidget = () => {
|
|
46
|
+
if (!lastCtx?.hasUI) return;
|
|
47
|
+
const running = registry.running().length;
|
|
48
|
+
lastCtx.ui.setWidget("cc-background", running > 0 ? [` background tasks: ${running} running`] : undefined);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Harness-injected notifications carry anti-confabulation framing for the
|
|
52
|
+
// model; the transcript shows a compact headline instead (ctrl+o expands).
|
|
53
|
+
// One registration covers every emitter of the type (bash uses it too).
|
|
54
|
+
for (const customType of ["task-notification", "wakeup"]) {
|
|
55
|
+
pi.registerMessageRenderer(customType, (message, { expanded }, theme) =>
|
|
56
|
+
notificationComponent(theme, customMessageText(message.content), expanded),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const notify = (customType: string, text: string, details: Record<string, unknown>) => {
|
|
61
|
+
pi.sendMessage(
|
|
62
|
+
{ customType, content: [{ type: "text", text }], display: true, details },
|
|
63
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
pi.registerTool({
|
|
68
|
+
name: "monitor",
|
|
69
|
+
label: "Monitor",
|
|
70
|
+
...ccToolRenderers("Monitor"),
|
|
71
|
+
description:
|
|
72
|
+
"Start a background monitor that streams events from a long-running command. Each stdout line becomes a notification delivered to the conversation while you keep working; the command exiting ends the watch. Use for 'tell me every time X happens' (tail -f, polling loops); for a single completion signal prefer a subagent or a blocking command. Returns a task id — stop with task_stop, inspect with task_output. Alternatively pass `ws` to watch a WebSocket (each text frame is an event).",
|
|
73
|
+
parameters: Type.Object({
|
|
74
|
+
command: Type.Optional(Type.String({ description: "Shell command; each stdout line is an event, exit ends the watch" })),
|
|
75
|
+
description: Type.String({ description: "Short description of what is being monitored (shown in notifications)" }),
|
|
76
|
+
persistent: Type.Optional(Type.Boolean({ description: "Run for the session lifetime (no timeout); stop with task_stop" })),
|
|
77
|
+
timeout_ms: Type.Optional(
|
|
78
|
+
Type.Number({ minimum: 1000, description: `Kill the monitor after this deadline (default ${DEFAULT_MONITOR_TIMEOUT_MS}, max ${MAX_MONITOR_TIMEOUT_MS}); ignored when persistent` }),
|
|
79
|
+
),
|
|
80
|
+
ws: Type.Optional(
|
|
81
|
+
Type.Object(
|
|
82
|
+
{
|
|
83
|
+
url: Type.String(),
|
|
84
|
+
protocols: Type.Optional(Type.Array(Type.String())),
|
|
85
|
+
},
|
|
86
|
+
{ description: "WebSocket to watch instead of a command; each text frame is an event, close ends the watch" },
|
|
87
|
+
),
|
|
88
|
+
),
|
|
89
|
+
}),
|
|
90
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
91
|
+
lastCtx = ctx;
|
|
92
|
+
if (Boolean(params.command) === Boolean(params.ws)) {
|
|
93
|
+
return {
|
|
94
|
+
content: [{ type: "text", text: "Pass exactly one of `command` or `ws`." }],
|
|
95
|
+
details: {},
|
|
96
|
+
isError: true,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const id = generateTaskId();
|
|
101
|
+
let status: BackgroundTask["status"] = "running";
|
|
102
|
+
let stored = "";
|
|
103
|
+
let eventCount = 0;
|
|
104
|
+
let pending: string[] = [];
|
|
105
|
+
let flushTimer: NodeJS.Timeout | undefined;
|
|
106
|
+
let finish!: () => void;
|
|
107
|
+
const finished = new Promise<void>((resolve) => {
|
|
108
|
+
finish = resolve;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const flush = () => {
|
|
112
|
+
flushTimer = undefined;
|
|
113
|
+
if (pending.length === 0) return;
|
|
114
|
+
const batch = pending;
|
|
115
|
+
pending = [];
|
|
116
|
+
notify(
|
|
117
|
+
"task-notification",
|
|
118
|
+
systemNotification(`Monitor ${id} (${params.description}) emitted ${batch.length} event(s):\n${batch.join("\n")}`),
|
|
119
|
+
{ taskId: id, events: batch.length },
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const onEvent = (line: string) => {
|
|
124
|
+
eventCount++;
|
|
125
|
+
stored = tail(`${stored}${line}\n`, STORED_OUTPUT_CAP);
|
|
126
|
+
pending.push(line);
|
|
127
|
+
flushTimer ??= setTimeout(flush, EVENT_BATCH_MS);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const end = (finalStatus: BackgroundTask["status"], note?: string) => {
|
|
131
|
+
if (status !== "running") return;
|
|
132
|
+
status = finalStatus;
|
|
133
|
+
task.status = finalStatus;
|
|
134
|
+
task.finishedAt = Date.now();
|
|
135
|
+
if (flushTimer) clearTimeout(flushTimer);
|
|
136
|
+
flush();
|
|
137
|
+
finish();
|
|
138
|
+
updateWidget();
|
|
139
|
+
notify(
|
|
140
|
+
"task-notification",
|
|
141
|
+
systemNotification(`Monitor ${id} (${params.description}) ${finalStatus}${note ? ` — ${note}` : ""} after ${eventCount} event(s).`),
|
|
142
|
+
{ taskId: id, status: finalStatus },
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
let stopRequested = false;
|
|
147
|
+
let stop: () => void;
|
|
148
|
+
if (params.command) {
|
|
149
|
+
const child = spawn(process.env.SHELL || "/bin/sh", ["-c", params.command], {
|
|
150
|
+
cwd: ctx.cwd,
|
|
151
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
152
|
+
});
|
|
153
|
+
let buffer = "";
|
|
154
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
155
|
+
buffer += chunk.toString();
|
|
156
|
+
let idx: number;
|
|
157
|
+
while ((idx = buffer.indexOf("\n")) !== -1) {
|
|
158
|
+
const line = buffer.slice(0, idx).trimEnd();
|
|
159
|
+
buffer = buffer.slice(idx + 1);
|
|
160
|
+
if (line) onEvent(line);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
164
|
+
stored = tail(`${stored}${chunk.toString()}`, STORED_OUTPUT_CAP);
|
|
165
|
+
});
|
|
166
|
+
child.on("error", (error) => end("failed", error.message));
|
|
167
|
+
child.on("close", (code) =>
|
|
168
|
+
end(
|
|
169
|
+
stopRequested ? "stopped" : code === 0 ? "completed" : "failed",
|
|
170
|
+
code !== null && code !== 0 ? `exit code ${code}` : undefined,
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
stop = () => {
|
|
174
|
+
stopRequested = true;
|
|
175
|
+
child.kill("SIGTERM");
|
|
176
|
+
};
|
|
177
|
+
} else {
|
|
178
|
+
let ws: WebSocket;
|
|
179
|
+
try {
|
|
180
|
+
ws = new WebSocket(params.ws!.url, params.ws!.protocols);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return {
|
|
183
|
+
content: [
|
|
184
|
+
{
|
|
185
|
+
type: "text",
|
|
186
|
+
text: `Could not open the WebSocket — check \`ws.url\` ("${params.ws!.url}"): ${(error as Error).message}`,
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
details: {},
|
|
190
|
+
isError: true,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
ws.addEventListener("message", (event) => {
|
|
194
|
+
onEvent(typeof event.data === "string" ? event.data : "[binary frame]");
|
|
195
|
+
});
|
|
196
|
+
ws.addEventListener("error", () => end("failed", "websocket error"));
|
|
197
|
+
ws.addEventListener("close", () => end(stopRequested ? "stopped" : "completed", "socket closed"));
|
|
198
|
+
stop = () => {
|
|
199
|
+
stopRequested = true;
|
|
200
|
+
ws.close();
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const task: BackgroundTask = {
|
|
205
|
+
id,
|
|
206
|
+
kind: "monitor",
|
|
207
|
+
description: params.description,
|
|
208
|
+
status,
|
|
209
|
+
startedAt: Date.now(),
|
|
210
|
+
output: () => stored,
|
|
211
|
+
stop,
|
|
212
|
+
finished,
|
|
213
|
+
};
|
|
214
|
+
registry.register(task);
|
|
215
|
+
updateWidget();
|
|
216
|
+
|
|
217
|
+
if (!params.persistent) {
|
|
218
|
+
const timeoutMs = Math.min(params.timeout_ms ?? DEFAULT_MONITOR_TIMEOUT_MS, MAX_MONITOR_TIMEOUT_MS);
|
|
219
|
+
const timer = setTimeout(() => {
|
|
220
|
+
if (task.status === "running") stop();
|
|
221
|
+
}, timeoutMs);
|
|
222
|
+
timer.unref?.();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
content: [
|
|
227
|
+
{
|
|
228
|
+
type: "text",
|
|
229
|
+
text: `Monitor ${id} started (${params.description}). Events arrive as system notifications; stop with task_stop, inspect with task_output.`,
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
details: { taskId: id },
|
|
233
|
+
};
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
pi.registerTool({
|
|
238
|
+
name: "task_output",
|
|
239
|
+
label: "Task Output",
|
|
240
|
+
...ccToolRenderers("Task Output"),
|
|
241
|
+
description:
|
|
242
|
+
"Retrieve output from a running or finished background task (monitor, background subagent, or background bash) by task id. block=true (default) waits up to `timeout` ms for completion; block=false returns the current status immediately. You never need this just to learn that a task finished — completion always arrives as a system notification carrying the output; call this only when your next step needs the result now, or for a mid-run peek.",
|
|
243
|
+
parameters: Type.Object({
|
|
244
|
+
task_id: Type.String({ description: "The task id to get output from" }),
|
|
245
|
+
block: Type.Optional(Type.Boolean({ description: "Wait for completion (default true)" })),
|
|
246
|
+
timeout: Type.Optional(Type.Number({ minimum: 0, maximum: MAX_BLOCK_TIMEOUT_MS, description: "Max wait in ms (default 30000)" })),
|
|
247
|
+
}),
|
|
248
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
249
|
+
lastCtx = ctx;
|
|
250
|
+
const task = registry.get(params.task_id);
|
|
251
|
+
if (!task) {
|
|
252
|
+
const known = registry.list().map((t) => t.id).join(", ") || "(none)";
|
|
253
|
+
return {
|
|
254
|
+
content: [{ type: "text", text: `No background task "${params.task_id}". Known tasks: ${known}` }],
|
|
255
|
+
details: {},
|
|
256
|
+
isError: true,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (params.block !== false && task.status === "running") {
|
|
261
|
+
const timeoutMs = Math.min(params.timeout ?? 30_000, MAX_BLOCK_TIMEOUT_MS);
|
|
262
|
+
await Promise.race([
|
|
263
|
+
task.finished,
|
|
264
|
+
new Promise<void>((resolve) => {
|
|
265
|
+
const timer = setTimeout(resolve, timeoutMs);
|
|
266
|
+
timer.unref?.();
|
|
267
|
+
signal?.addEventListener("abort", () => {
|
|
268
|
+
clearTimeout(timer);
|
|
269
|
+
resolve();
|
|
270
|
+
}, { once: true });
|
|
271
|
+
}),
|
|
272
|
+
]);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
updateWidget();
|
|
276
|
+
const header = formatTaskLine(task);
|
|
277
|
+
const body = tail(task.output(), OUTPUT_CAP) || "(no output yet)";
|
|
278
|
+
return {
|
|
279
|
+
content: [{ type: "text", text: `${header}\n\n${body}` }],
|
|
280
|
+
details: { taskId: task.id, status: task.status, logPath: task.logPath },
|
|
281
|
+
};
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
pi.registerTool({
|
|
286
|
+
name: "task_stop",
|
|
287
|
+
label: "Stop Task",
|
|
288
|
+
...ccToolRenderers("Stop Task"),
|
|
289
|
+
description: "Stop a running background task (monitor, background subagent, or background bash) by task id.",
|
|
290
|
+
parameters: Type.Object({
|
|
291
|
+
task_id: Type.String({ description: "The id of the background task to stop" }),
|
|
292
|
+
}),
|
|
293
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
294
|
+
lastCtx = ctx;
|
|
295
|
+
const task = registry.get(params.task_id);
|
|
296
|
+
if (!task) {
|
|
297
|
+
const known = registry.list().map((t) => t.id).join(", ") || "(none)";
|
|
298
|
+
return {
|
|
299
|
+
content: [{ type: "text", text: `No background task "${params.task_id}". Known tasks: ${known}` }],
|
|
300
|
+
details: {},
|
|
301
|
+
isError: true,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
if (task.status !== "running" && !task.resident?.()) {
|
|
305
|
+
return { content: [{ type: "text", text: `Task ${task.id} is already ${task.status}.` }], details: { taskId: task.id } };
|
|
306
|
+
}
|
|
307
|
+
const wasResident = task.status !== "running";
|
|
308
|
+
task.stop();
|
|
309
|
+
updateWidget();
|
|
310
|
+
return {
|
|
311
|
+
content: [
|
|
312
|
+
{
|
|
313
|
+
type: "text",
|
|
314
|
+
text: wasResident
|
|
315
|
+
? `Terminated the resident agent behind ${task.id} (${task.description}); it can no longer be messaged live (send_message will resume it from its session file).`
|
|
316
|
+
: `Stop requested for ${task.id} (${task.description}).`,
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
details: { taskId: task.id },
|
|
320
|
+
};
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
pi.registerTool({
|
|
325
|
+
name: "schedule_wakeup",
|
|
326
|
+
label: "Schedule Wakeup",
|
|
327
|
+
...ccToolRenderers<{ delaySeconds?: number; stop?: boolean }>("Schedule Wakeup", {
|
|
328
|
+
title: (a) => (a?.stop ? "stop" : a?.delaySeconds !== undefined ? `${a.delaySeconds}s` : undefined),
|
|
329
|
+
}),
|
|
330
|
+
description:
|
|
331
|
+
"Schedule when to resume work on a self-paced recurring task: after `delaySeconds` (clamped to [60, 3600]) the given prompt is delivered as a system notification and a new turn starts. One wakeup is pending at a time — scheduling again replaces it; {stop: true} cancels the loop. Do not use this to poll background tasks you started here — their completion already notifies you.",
|
|
332
|
+
parameters: Type.Object({
|
|
333
|
+
delaySeconds: Type.Optional(Type.Number({ description: "Seconds from now to wake up, clamped to [60, 3600]. Required unless stop is true" })),
|
|
334
|
+
prompt: Type.Optional(Type.String({ description: "The task to continue when the wakeup fires. Required unless stop is true" })),
|
|
335
|
+
reason: Type.Optional(Type.String({ description: "One short sentence explaining the chosen delay; shown to the user. Required unless stop is true" })),
|
|
336
|
+
stop: Type.Optional(Type.Boolean({ description: "End the loop: cancel any pending wakeup and schedule nothing" })),
|
|
337
|
+
}),
|
|
338
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
339
|
+
lastCtx = ctx;
|
|
340
|
+
const clearPending = () => {
|
|
341
|
+
if (wakeup) {
|
|
342
|
+
clearTimeout(wakeup.timer);
|
|
343
|
+
wakeup = undefined;
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
if (params.stop) {
|
|
347
|
+
clearPending();
|
|
348
|
+
return { content: [{ type: "text", text: "Wakeup loop stopped; no further wakeups will fire." }], details: {} };
|
|
349
|
+
}
|
|
350
|
+
// Validate BEFORE touching the pending wakeup: clearing it up front meant
|
|
351
|
+
// a malformed reschedule silently killed a running /loop with no signal.
|
|
352
|
+
if (params.delaySeconds === undefined || !params.prompt || !params.reason) {
|
|
353
|
+
return {
|
|
354
|
+
content: [
|
|
355
|
+
{
|
|
356
|
+
type: "text",
|
|
357
|
+
text: `delaySeconds, prompt, and reason are all required unless stop is true.${wakeup ? " Your previous wakeup is still pending — this call was rejected and left it untouched." : ""}`,
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
details: {},
|
|
361
|
+
isError: true,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
// Valid reschedule — now it is safe to replace any pending wakeup.
|
|
365
|
+
clearPending();
|
|
366
|
+
|
|
367
|
+
const request = { delaySeconds: params.delaySeconds, prompt: params.prompt, reason: params.reason };
|
|
368
|
+
const delayMs = clampDelaySeconds(params.delaySeconds) * 1000;
|
|
369
|
+
const timer = setTimeout(() => {
|
|
370
|
+
wakeup = undefined;
|
|
371
|
+
notify("wakeup", buildWakeupMessage(request), { reason: request.reason });
|
|
372
|
+
}, delayMs);
|
|
373
|
+
timer.unref?.();
|
|
374
|
+
wakeup = { timer, prompt: params.prompt, reason: params.reason };
|
|
375
|
+
return { content: [{ type: "text", text: describeSchedule(request) }], details: { delayMs } };
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
for (const [name, keywords] of Object.entries({
|
|
380
|
+
monitor: ["monitor", "watch", "background", "tail", "events", "stream", "websocket"],
|
|
381
|
+
task_output: ["task", "background", "output", "status", "wait"],
|
|
382
|
+
task_stop: ["task", "background", "stop", "kill", "cancel"],
|
|
383
|
+
schedule_wakeup: ["wakeup", "loop", "schedule", "timer", "recurring", "later"],
|
|
384
|
+
})) {
|
|
385
|
+
pi.events.emit(DEFER_CHANNEL, { name, keywords });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
pi.registerCommand("background", {
|
|
389
|
+
description: "List background tasks (monitors, background subagents)",
|
|
390
|
+
handler: async (_args, ctx) => {
|
|
391
|
+
const tasks = registry.list();
|
|
392
|
+
ctx.ui.notify(tasks.length ? tasks.map(formatTaskLine).join("\n") : "No background tasks.", "info");
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
pi.on("session_start", (_event, ctx) => {
|
|
397
|
+
lastCtx = ctx;
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
pi.on("session_shutdown", () => {
|
|
401
|
+
registry.stopAll();
|
|
402
|
+
if (wakeup) clearTimeout(wakeup.timer);
|
|
403
|
+
wakeup = undefined;
|
|
404
|
+
});
|
|
405
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background-task registry (pure) — the id space behind task_output/task_stop.
|
|
3
|
+
*
|
|
4
|
+
* Anything long-running registers here: monitors (owned by this extension) and
|
|
5
|
+
* background subagent runs (registered by the subagents extension over the
|
|
6
|
+
* event bus, since module state does not cross extension boundaries).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
10
|
+
|
|
11
|
+
/** Runtime event-bus channel other extensions use to register their tasks. */
|
|
12
|
+
export const TASK_REGISTER_CHANNEL = "one-code:background-task";
|
|
13
|
+
|
|
14
|
+
export type BackgroundStatus = "running" | "completed" | "failed" | "stopped";
|
|
15
|
+
|
|
16
|
+
export interface BackgroundTask {
|
|
17
|
+
id: string;
|
|
18
|
+
kind: string;
|
|
19
|
+
description: string;
|
|
20
|
+
status: BackgroundStatus;
|
|
21
|
+
startedAt: number;
|
|
22
|
+
finishedAt?: number;
|
|
23
|
+
/** File the task spools its output to, when it has one. */
|
|
24
|
+
logPath?: string;
|
|
25
|
+
/** Output accumulated so far (or final output once finished). */
|
|
26
|
+
output(): string;
|
|
27
|
+
stop(): void;
|
|
28
|
+
/** Settles when the task reaches a terminal status; never rejects. */
|
|
29
|
+
finished: Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* True while the underlying process outlives the task (a background agent
|
|
32
|
+
* stays resident after its run so it can be messaged). Lets task_stop
|
|
33
|
+
* terminate it even though the task itself already completed.
|
|
34
|
+
*/
|
|
35
|
+
resident?: () => boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function generateTaskId(): string {
|
|
39
|
+
return `b${randomBytes(4).toString("hex").slice(0, 7)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class BackgroundRegistry {
|
|
43
|
+
private tasks = new Map<string, BackgroundTask>();
|
|
44
|
+
|
|
45
|
+
register(task: BackgroundTask): void {
|
|
46
|
+
if (!task?.id) return;
|
|
47
|
+
this.tasks.set(task.id, task);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Exact id first, then unique prefix match. */
|
|
51
|
+
get(id: string): BackgroundTask | undefined {
|
|
52
|
+
const exact = this.tasks.get(id);
|
|
53
|
+
if (exact) return exact;
|
|
54
|
+
const matches = [...this.tasks.values()].filter((t) => t.id.startsWith(id));
|
|
55
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
list(): BackgroundTask[] {
|
|
59
|
+
return [...this.tasks.values()];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
running(): BackgroundTask[] {
|
|
63
|
+
return this.list().filter((t) => t.status === "running");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
stopAll(): void {
|
|
67
|
+
for (const task of this.running()) {
|
|
68
|
+
try {
|
|
69
|
+
task.stop();
|
|
70
|
+
} catch {
|
|
71
|
+
// Shutdown must not fail because one task's stop() threw.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function formatTaskLine(task: BackgroundTask): string {
|
|
78
|
+
const age = Math.round((Date.now() - task.startedAt) / 1000);
|
|
79
|
+
const duration = task.finishedAt ? `${Math.round((task.finishedAt - task.startedAt) / 1000)}s` : `${age}s ago`;
|
|
80
|
+
return `${task.id} [${task.kind}] ${task.status} · ${task.description} · started ${duration}`;
|
|
81
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schedule_wakeup helpers (pure) — Claude Code's self-paced /loop timer.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { systemNotification } from "../lib/notifications.ts";
|
|
6
|
+
|
|
7
|
+
export const MIN_DELAY_SECONDS = 60;
|
|
8
|
+
export const MAX_DELAY_SECONDS = 3600;
|
|
9
|
+
|
|
10
|
+
export function clampDelaySeconds(delay: number): number {
|
|
11
|
+
if (!Number.isFinite(delay)) return MIN_DELAY_SECONDS;
|
|
12
|
+
return Math.min(MAX_DELAY_SECONDS, Math.max(MIN_DELAY_SECONDS, Math.round(delay)));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface WakeupRequest {
|
|
16
|
+
delaySeconds: number;
|
|
17
|
+
prompt: string;
|
|
18
|
+
reason: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The follow-up message a fired wakeup delivers. Framed so it never reads as user input. */
|
|
22
|
+
export function buildWakeupMessage(request: WakeupRequest): string {
|
|
23
|
+
return systemNotification(
|
|
24
|
+
[
|
|
25
|
+
`Scheduled wake-up fired (reason given when scheduled: ${request.reason}).`,
|
|
26
|
+
"Continue the task below; when done, either schedule the next wake-up with schedule_wakeup or end the loop with schedule_wakeup {stop: true}.",
|
|
27
|
+
"",
|
|
28
|
+
request.prompt,
|
|
29
|
+
].join("\n"),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function describeSchedule(request: WakeupRequest): string {
|
|
34
|
+
const clamped = clampDelaySeconds(request.delaySeconds);
|
|
35
|
+
const minutes = Math.round((clamped / 60) * 10) / 10;
|
|
36
|
+
const adjusted =
|
|
37
|
+
clamped !== request.delaySeconds
|
|
38
|
+
? ` (adjusted from ${request.delaySeconds}s — the allowed range is ${MIN_DELAY_SECONDS}-${MAX_DELAY_SECONDS}s)`
|
|
39
|
+
: "";
|
|
40
|
+
return `Wake-up scheduled in ${clamped}s (~${minutes}min)${adjusted}: ${request.reason}`;
|
|
41
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background bash execution (pure module) — the process/spool half of the
|
|
3
|
+
* bash override in index.ts.
|
|
4
|
+
*
|
|
5
|
+
* A background command spawns detached in its own process group, spools
|
|
6
|
+
* stdout+stderr to memory (tail-capped) and to a log file when one is given,
|
|
7
|
+
* and satisfies the BackgroundTask contract so task_output/task_stop work
|
|
8
|
+
* unchanged. Robustness rules from docs/plan/background-bash.md: `output()`
|
|
9
|
+
* never returns an empty body for a finished task (an explicitly-marked
|
|
10
|
+
* "(no output)" beats an ambiguous blank a weak model reads as failure), and
|
|
11
|
+
* the completion callback receives exactly what `output()` returns, so the
|
|
12
|
+
* notification and task_output can never disagree.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
16
|
+
import { createWriteStream } from "node:fs";
|
|
17
|
+
import type { BackgroundTask } from "../background/registry.ts";
|
|
18
|
+
|
|
19
|
+
export const STORED_OUTPUT_CAP = 200_000;
|
|
20
|
+
|
|
21
|
+
export const EMPTY_OUTPUT_MARKER = "(no output — the command wrote nothing to stdout or stderr)";
|
|
22
|
+
|
|
23
|
+
export function tailCap(text: string, cap: number): string {
|
|
24
|
+
return text.length <= cap ? text : `… (earlier output truncated)\n${text.slice(-cap)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface BashFinishSummary {
|
|
28
|
+
exitCode: number | null;
|
|
29
|
+
signal: NodeJS.Signals | null;
|
|
30
|
+
/** task_stop (or session shutdown) ended it. */
|
|
31
|
+
stopped: boolean;
|
|
32
|
+
/** The `timeout` deadline killed it. */
|
|
33
|
+
timedOut: boolean;
|
|
34
|
+
/** Exactly what task.output() returns — never empty. */
|
|
35
|
+
output: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface StartBackgroundBashOptions {
|
|
39
|
+
id: string;
|
|
40
|
+
command: string;
|
|
41
|
+
description: string;
|
|
42
|
+
cwd: string;
|
|
43
|
+
/** Kill the process tree after this many seconds. */
|
|
44
|
+
timeoutSeconds?: number;
|
|
45
|
+
logPath?: string;
|
|
46
|
+
onFinished(task: BackgroundTask, summary: BashFinishSummary): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function killTree(child: ChildProcess): void {
|
|
50
|
+
if (child.pid == null) return;
|
|
51
|
+
// Detached → own process group; negative pid signals the whole tree.
|
|
52
|
+
try {
|
|
53
|
+
process.kill(-child.pid, "SIGTERM");
|
|
54
|
+
} catch {
|
|
55
|
+
try {
|
|
56
|
+
child.kill("SIGTERM");
|
|
57
|
+
} catch {
|
|
58
|
+
// Already gone.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function startBackgroundBash(options: StartBackgroundBashOptions): BackgroundTask {
|
|
64
|
+
const child = spawn(process.env.SHELL || "/bin/sh", ["-c", options.command], {
|
|
65
|
+
cwd: options.cwd,
|
|
66
|
+
detached: process.platform !== "win32",
|
|
67
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
let stored = "";
|
|
71
|
+
let ended = false;
|
|
72
|
+
let stopRequested = false;
|
|
73
|
+
let timedOut = false;
|
|
74
|
+
const log = options.logPath ? createWriteStream(options.logPath, { flags: "a" }) : undefined;
|
|
75
|
+
log?.on("error", () => {
|
|
76
|
+
// Spooling to disk is best-effort; the in-memory tail stays authoritative.
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const append = (chunk: Buffer) => {
|
|
80
|
+
const text = chunk.toString();
|
|
81
|
+
stored = tailCap(stored + text, STORED_OUTPUT_CAP);
|
|
82
|
+
log?.write(text);
|
|
83
|
+
};
|
|
84
|
+
child.stdout?.on("data", append);
|
|
85
|
+
child.stderr?.on("data", append);
|
|
86
|
+
|
|
87
|
+
let finish!: () => void;
|
|
88
|
+
const finished = new Promise<void>((resolve) => {
|
|
89
|
+
finish = resolve;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const task: BackgroundTask = {
|
|
93
|
+
id: options.id,
|
|
94
|
+
kind: "bash",
|
|
95
|
+
description: options.description,
|
|
96
|
+
status: "running",
|
|
97
|
+
startedAt: Date.now(),
|
|
98
|
+
logPath: options.logPath,
|
|
99
|
+
output: () => stored || (task.status === "running" ? "" : EMPTY_OUTPUT_MARKER),
|
|
100
|
+
stop: () => {
|
|
101
|
+
stopRequested = true;
|
|
102
|
+
killTree(child);
|
|
103
|
+
},
|
|
104
|
+
finished,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
let timer: NodeJS.Timeout | undefined;
|
|
108
|
+
if (options.timeoutSeconds && options.timeoutSeconds > 0) {
|
|
109
|
+
timer = setTimeout(() => {
|
|
110
|
+
timedOut = true;
|
|
111
|
+
killTree(child);
|
|
112
|
+
}, options.timeoutSeconds * 1000);
|
|
113
|
+
timer.unref?.();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const end = (status: BackgroundTask["status"], exitCode: number | null, signal: NodeJS.Signals | null) => {
|
|
117
|
+
if (ended) return;
|
|
118
|
+
ended = true;
|
|
119
|
+
if (timer) clearTimeout(timer);
|
|
120
|
+
task.status = status;
|
|
121
|
+
task.finishedAt = Date.now();
|
|
122
|
+
log?.end();
|
|
123
|
+
finish();
|
|
124
|
+
options.onFinished(task, { exitCode, signal, stopped: stopRequested, timedOut, output: task.output() });
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
child.on("error", (error) => {
|
|
128
|
+
stored = stored ? `${stored}\n${error.message}` : error.message;
|
|
129
|
+
end("failed", null, null);
|
|
130
|
+
});
|
|
131
|
+
child.on("close", (code, signal) => {
|
|
132
|
+
end(stopRequested ? "stopped" : timedOut || code !== 0 ? "failed" : "completed", code, signal);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return task;
|
|
136
|
+
}
|