lazyopencode-core 0.0.1
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/ATTRIBUTION.md +38 -0
- package/LICENSE +21 -0
- package/README.md +357 -0
- package/dist/agents/councillor.d.ts +1 -0
- package/dist/agents/councillor.js +14 -0
- package/dist/agents/designer.d.ts +1 -0
- package/dist/agents/designer.js +31 -0
- package/dist/agents/explorer.d.ts +1 -0
- package/dist/agents/explorer.js +15 -0
- package/dist/agents/fixer.d.ts +1 -0
- package/dist/agents/fixer.js +23 -0
- package/dist/agents/index.d.ts +2 -0
- package/dist/agents/index.js +55 -0
- package/dist/agents/lazy.d.ts +1 -0
- package/dist/agents/lazy.js +3 -0
- package/dist/agents/librarian.d.ts +1 -0
- package/dist/agents/librarian.js +26 -0
- package/dist/agents/observer.d.ts +1 -0
- package/dist/agents/observer.js +20 -0
- package/dist/agents/oracle.d.ts +1 -0
- package/dist/agents/oracle.js +30 -0
- package/dist/council/council-manager.d.ts +42 -0
- package/dist/council/council-manager.js +223 -0
- package/dist/council/index.d.ts +2 -0
- package/dist/council/index.js +1 -0
- package/dist/hooks/apply-patch-rescue.d.ts +7 -0
- package/dist/hooks/apply-patch-rescue.js +150 -0
- package/dist/hooks/background-job-board.d.ts +92 -0
- package/dist/hooks/background-job-board.js +452 -0
- package/dist/hooks/chat-params.d.ts +16 -0
- package/dist/hooks/chat-params.js +30 -0
- package/dist/hooks/deepwork.d.ts +9 -0
- package/dist/hooks/deepwork.js +55 -0
- package/dist/hooks/error-recovery.d.ts +21 -0
- package/dist/hooks/error-recovery.js +216 -0
- package/dist/hooks/index.d.ts +3 -0
- package/dist/hooks/index.js +61 -0
- package/dist/hooks/lazy-command.d.ts +16 -0
- package/dist/hooks/lazy-command.js +178 -0
- package/dist/hooks/messages-transform.d.ts +40 -0
- package/dist/hooks/messages-transform.js +358 -0
- package/dist/hooks/permission-guard.d.ts +5 -0
- package/dist/hooks/permission-guard.js +38 -0
- package/dist/hooks/runtime.d.ts +169 -0
- package/dist/hooks/runtime.js +653 -0
- package/dist/hooks/session-events.d.ts +16 -0
- package/dist/hooks/session-events.js +65 -0
- package/dist/hooks/system-transform.d.ts +8 -0
- package/dist/hooks/system-transform.js +113 -0
- package/dist/hooks/task-session.d.ts +32 -0
- package/dist/hooks/task-session.js +177 -0
- package/dist/hooks/workflow-classifier.d.ts +17 -0
- package/dist/hooks/workflow-classifier.js +170 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +85 -0
- package/dist/opencode-control-plane.d.ts +20 -0
- package/dist/opencode-control-plane.js +95 -0
- package/dist/ponytail.d.ts +1 -0
- package/dist/ponytail.js +33 -0
- package/dist/skills/index.d.ts +5 -0
- package/dist/skills/index.js +10 -0
- package/dist/skills/lazy/build/SKILL.md +62 -0
- package/dist/skills/lazy/debug/SKILL.md +17 -0
- package/dist/skills/lazy/grill/SKILL.md +54 -0
- package/dist/skills/lazy/plan/SKILL.md +52 -0
- package/dist/skills/lazy/review/SKILL.md +29 -0
- package/dist/skills/lazy/security/SKILL.md +29 -0
- package/dist/skills/lazy/simplify/SKILL.md +52 -0
- package/dist/skills/lazy/specify/SKILL.md +62 -0
- package/dist/skills/lazy/worktree/SKILL.md +66 -0
- package/dist/tools/cancel-task.d.ts +3 -0
- package/dist/tools/cancel-task.js +37 -0
- package/dist/tools/council.d.ts +6 -0
- package/dist/tools/council.js +41 -0
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.js +2 -0
- package/dist/v2.d.ts +1 -0
- package/dist/v2.js +42 -0
- package/docs/architecture.md +47 -0
- package/docs/council.md +200 -0
- package/docs/desktop-distribution.md +36 -0
- package/docs/opencode-integration.md +54 -0
- package/docs/positioning.md +44 -0
- package/docs/product-audit.md +187 -0
- package/docs/product-plan.md +56 -0
- package/docs/state-machine.md +35 -0
- package/docs/user-manual.md +439 -0
- package/docs/work-plan.md +190 -0
- package/package.json +44 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BackgroundJobBoard — in-memory state machine for subagent task tracking.
|
|
3
|
+
*
|
|
4
|
+
* ponytail: global singletons, no DI. Upgrade: per-session isolation if starvation occurs.
|
|
5
|
+
*/
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Config
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
const _MAX_SESSIONS_PER_AGENT = 2;
|
|
10
|
+
const MAX_PENDING_CALLS = 100;
|
|
11
|
+
const MAX_INJECTED_COMPLETIONS = 500;
|
|
12
|
+
const CONTEXT_MIN_LINES = 10;
|
|
13
|
+
const CONTEXT_MAX_FILES = 8;
|
|
14
|
+
function findJobByTaskID(jobs, taskID) {
|
|
15
|
+
for (const job of jobs.values()) {
|
|
16
|
+
if (job.taskID === taskID)
|
|
17
|
+
return job;
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
function findJobByCallIDScan(jobs, callID) {
|
|
22
|
+
for (const job of jobs.values()) {
|
|
23
|
+
if (job.callID === callID)
|
|
24
|
+
return job;
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
function isReusableJob(job) {
|
|
29
|
+
return job.state === "reconciled" && !job.terminalUnreconciled;
|
|
30
|
+
}
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Board
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
export class BackgroundJobBoard {
|
|
35
|
+
jobs = new Map();
|
|
36
|
+
pendingCalls = [];
|
|
37
|
+
agentCounter = new Map();
|
|
38
|
+
processedCompletions = new Set();
|
|
39
|
+
injectedCompletionsSeen = new Set();
|
|
40
|
+
maxReusablePerAgent = 2;
|
|
41
|
+
dirty = false;
|
|
42
|
+
constructor(options) {
|
|
43
|
+
if (options?.maxReusablePerAgent) {
|
|
44
|
+
this.maxReusablePerAgent = options.maxReusablePerAgent;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
configure(options) {
|
|
48
|
+
if (options.maxReusablePerAgent) {
|
|
49
|
+
this.maxReusablePerAgent = options.maxReusablePerAgent;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// -----------------------------------------------------------------------
|
|
53
|
+
// Launch
|
|
54
|
+
// -----------------------------------------------------------------------
|
|
55
|
+
registerLaunch(parentSessionID, agent, callID) {
|
|
56
|
+
const taskID = Date.now().toString(36).slice(-4) + Math.random().toString(36).slice(2, 6); // placeholder; real taskID set in parseLaunch
|
|
57
|
+
const count = this.agentCounter.get(agent) ?? 0;
|
|
58
|
+
this.agentCounter.set(agent, count + 1);
|
|
59
|
+
const alias = `${agent}-${count + 1}`;
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
const job = {
|
|
62
|
+
taskID,
|
|
63
|
+
parentSessionID,
|
|
64
|
+
agent,
|
|
65
|
+
state: "running",
|
|
66
|
+
terminalUnreconciled: false,
|
|
67
|
+
timedOut: false,
|
|
68
|
+
cancellationRequested: false,
|
|
69
|
+
alias,
|
|
70
|
+
callID,
|
|
71
|
+
contextFiles: [],
|
|
72
|
+
launchedAt: now,
|
|
73
|
+
lastLaunchedAt: now,
|
|
74
|
+
lastUsedAt: now,
|
|
75
|
+
updatedAt: now,
|
|
76
|
+
completedAt: 0,
|
|
77
|
+
};
|
|
78
|
+
// Store by alias (used for lookup before real taskID is known)
|
|
79
|
+
this.jobs.set(alias, job);
|
|
80
|
+
// Track pending call
|
|
81
|
+
this.pendingCalls.push({ callID, sessionID: parentSessionID, alias });
|
|
82
|
+
if (this.pendingCalls.length > MAX_PENDING_CALLS) {
|
|
83
|
+
this.pendingCalls.shift();
|
|
84
|
+
}
|
|
85
|
+
this.dirty = true;
|
|
86
|
+
return job;
|
|
87
|
+
}
|
|
88
|
+
// -----------------------------------------------------------------------
|
|
89
|
+
// Match pending call to job
|
|
90
|
+
// -----------------------------------------------------------------------
|
|
91
|
+
findJobByCallID(callID) {
|
|
92
|
+
const pending = this.pendingCalls.find((p) => p.callID === callID);
|
|
93
|
+
if (!pending?.alias)
|
|
94
|
+
return undefined;
|
|
95
|
+
return this.jobs.get(pending.alias) ?? findJobByCallIDScan(this.jobs, callID);
|
|
96
|
+
}
|
|
97
|
+
findJobByTaskID(taskID) {
|
|
98
|
+
return findJobByTaskID(this.jobs, taskID);
|
|
99
|
+
}
|
|
100
|
+
findJobByAlias(alias) {
|
|
101
|
+
for (const job of this.jobs.values()) {
|
|
102
|
+
if (job.alias === alias)
|
|
103
|
+
return job;
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
// -----------------------------------------------------------------------
|
|
108
|
+
// Update state (called from tool.execute.after)
|
|
109
|
+
// -----------------------------------------------------------------------
|
|
110
|
+
updateStatus(callID, taskID, state, resultSummary) {
|
|
111
|
+
const job = this.findJobByCallID(callID);
|
|
112
|
+
if (!job)
|
|
113
|
+
return;
|
|
114
|
+
// Late-cancel normalization: if cancelled + error → force cancelled
|
|
115
|
+
if (state === "error" && job.cancellationRequested) {
|
|
116
|
+
state = "cancelled";
|
|
117
|
+
}
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
const oldTaskID = job.taskID;
|
|
120
|
+
job.taskID = taskID || job.taskID;
|
|
121
|
+
if (oldTaskID !== job.taskID)
|
|
122
|
+
this.jobs.delete(oldTaskID);
|
|
123
|
+
job.state = state;
|
|
124
|
+
job.updatedAt = now;
|
|
125
|
+
if (resultSummary)
|
|
126
|
+
job.resultSummary = resultSummary;
|
|
127
|
+
if (state === "completed" || state === "error" || state === "cancelled") {
|
|
128
|
+
job.completedAt = now;
|
|
129
|
+
job.terminalUnreconciled = true;
|
|
130
|
+
this.trimReusable(taskID);
|
|
131
|
+
}
|
|
132
|
+
// Re-index by taskID and drop stale alias key
|
|
133
|
+
this.jobs.set(taskID, job);
|
|
134
|
+
const pending = this.pendingCalls.find((p) => p.callID === callID);
|
|
135
|
+
if (pending?.alias)
|
|
136
|
+
this.jobs.delete(pending.alias);
|
|
137
|
+
this.dirty = true;
|
|
138
|
+
}
|
|
139
|
+
// -----------------------------------------------------------------------
|
|
140
|
+
// Context accumulation
|
|
141
|
+
// -----------------------------------------------------------------------
|
|
142
|
+
addContext(taskID, file) {
|
|
143
|
+
if (file.lineCount < CONTEXT_MIN_LINES)
|
|
144
|
+
return;
|
|
145
|
+
const job = findJobByTaskID(this.jobs, taskID);
|
|
146
|
+
if (!job || job.state !== "running")
|
|
147
|
+
return;
|
|
148
|
+
const existing = job.contextFiles.find((f) => f.path === file.path);
|
|
149
|
+
if (existing) {
|
|
150
|
+
existing.lineCount = Math.max(existing.lineCount, file.lineCount);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
job.contextFiles.push(file);
|
|
154
|
+
}
|
|
155
|
+
job.contextFiles = job.contextFiles
|
|
156
|
+
.sort((a, b) => b.lineCount - a.lineCount)
|
|
157
|
+
.slice(0, CONTEXT_MAX_FILES);
|
|
158
|
+
}
|
|
159
|
+
// -----------------------------------------------------------------------
|
|
160
|
+
// Reconciliation
|
|
161
|
+
// -----------------------------------------------------------------------
|
|
162
|
+
markReconciled(taskID) {
|
|
163
|
+
const job = findJobByTaskID(this.jobs, taskID);
|
|
164
|
+
if (job) {
|
|
165
|
+
job.terminalUnreconciled = false;
|
|
166
|
+
if (job.state === "completed" || job.state === "reconciled") {
|
|
167
|
+
job.state = "reconciled";
|
|
168
|
+
}
|
|
169
|
+
job.updatedAt = Date.now();
|
|
170
|
+
}
|
|
171
|
+
this.injectedCompletionsSeen.delete(taskID);
|
|
172
|
+
this.trimReusable(taskID);
|
|
173
|
+
this.dirty = true;
|
|
174
|
+
}
|
|
175
|
+
trimReusable(taskID) {
|
|
176
|
+
const job = findJobByTaskID(this.jobs, taskID);
|
|
177
|
+
if (!job || !isReusableJob(job))
|
|
178
|
+
return;
|
|
179
|
+
const reusable = [...this.jobs.values()]
|
|
180
|
+
.filter((j) => j.agent === job.agent &&
|
|
181
|
+
j.parentSessionID === job.parentSessionID &&
|
|
182
|
+
isReusableJob(j))
|
|
183
|
+
.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
|
184
|
+
for (const stale of reusable.slice(this.maxReusablePerAgent)) {
|
|
185
|
+
this.jobs.delete(stale.taskID);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
getTerminalUnreconciledJobs(parentSessionID) {
|
|
189
|
+
const result = [];
|
|
190
|
+
for (const job of this.jobs.values()) {
|
|
191
|
+
if (job.parentSessionID === parentSessionID &&
|
|
192
|
+
job.terminalUnreconciled) {
|
|
193
|
+
result.push(job);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
getRunningJobs(parentSessionID) {
|
|
199
|
+
const result = [];
|
|
200
|
+
for (const job of this.jobs.values()) {
|
|
201
|
+
if (job.parentSessionID === parentSessionID && job.state === "running") {
|
|
202
|
+
result.push(job);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
getStaleJobs(parentSessionID) {
|
|
208
|
+
const result = [];
|
|
209
|
+
for (const job of this.jobs.values()) {
|
|
210
|
+
if (job.parentSessionID === parentSessionID && job.state === "stale") {
|
|
211
|
+
result.push(job);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
getReusableJobs(parentSessionID) {
|
|
217
|
+
return [...this.jobs.values()].filter((job) => job.parentSessionID === parentSessionID && isReusableJob(job));
|
|
218
|
+
}
|
|
219
|
+
// -----------------------------------------------------------------------
|
|
220
|
+
// Session reuse
|
|
221
|
+
// -----------------------------------------------------------------------
|
|
222
|
+
resolveReusable(parentSessionID, agent) {
|
|
223
|
+
const candidates = [];
|
|
224
|
+
for (const job of this.jobs.values()) {
|
|
225
|
+
if (job.parentSessionID === parentSessionID &&
|
|
226
|
+
job.agent === agent &&
|
|
227
|
+
isReusableJob(job)) {
|
|
228
|
+
candidates.push(job);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// Return oldest reconciled session
|
|
232
|
+
if (candidates.length > 0) {
|
|
233
|
+
candidates.sort((a, b) => a.completedAt - b.completedAt);
|
|
234
|
+
const job = candidates[0];
|
|
235
|
+
const now = Date.now();
|
|
236
|
+
job.lastLaunchedAt = now;
|
|
237
|
+
job.lastUsedAt = now;
|
|
238
|
+
return job;
|
|
239
|
+
}
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
getReusableJob(taskID) {
|
|
243
|
+
const job = findJobByTaskID(this.jobs, taskID);
|
|
244
|
+
if (job && isReusableJob(job)) {
|
|
245
|
+
return job;
|
|
246
|
+
}
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
getActiveCount(parentSessionID, agent) {
|
|
250
|
+
let count = 0;
|
|
251
|
+
for (const job of this.jobs.values()) {
|
|
252
|
+
if (job.parentSessionID === parentSessionID &&
|
|
253
|
+
job.agent === agent &&
|
|
254
|
+
job.state === "running") {
|
|
255
|
+
count++;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return count;
|
|
259
|
+
}
|
|
260
|
+
isLateCancelledTaskError(callID) {
|
|
261
|
+
const job = this.findJobByCallID(callID);
|
|
262
|
+
return job?.cancellationRequested === true;
|
|
263
|
+
}
|
|
264
|
+
cancelJob(id) {
|
|
265
|
+
const job = findJobByTaskID(this.jobs, id) ?? this.findJobByAlias(id);
|
|
266
|
+
if (job) {
|
|
267
|
+
job.cancellationRequested = true;
|
|
268
|
+
this.dirty = true;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// -----------------------------------------------------------------------
|
|
272
|
+
// Prompt injection
|
|
273
|
+
// -----------------------------------------------------------------------
|
|
274
|
+
formatForPrompt(parentSessionID) {
|
|
275
|
+
const running = this.getRunningJobs(parentSessionID);
|
|
276
|
+
const terminal = this.getTerminalUnreconciledJobs(parentSessionID);
|
|
277
|
+
const reusable = this.getReusableJobs(parentSessionID);
|
|
278
|
+
const stale = this.getStaleJobs(parentSessionID);
|
|
279
|
+
if (running.length === 0 && terminal.length === 0 && reusable.length === 0 && stale.length === 0) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
const lines = ["[Background Job Board]"];
|
|
283
|
+
if (running.length > 0) {
|
|
284
|
+
lines.push(" Running:");
|
|
285
|
+
const now = Date.now();
|
|
286
|
+
for (const j of running) {
|
|
287
|
+
const ageMs = now - j.lastLaunchedAt;
|
|
288
|
+
const isResume = j.lastLaunchedAt !== j.launchedAt;
|
|
289
|
+
let ageLabel = "";
|
|
290
|
+
if (j.state === "running" && ageMs < 30000) {
|
|
291
|
+
ageLabel = isResume
|
|
292
|
+
? ` [resumed, ${Math.floor(ageMs / 1000)}s ago]`
|
|
293
|
+
: ` [just launched, ${Math.floor(ageMs / 1000)}s ago]`;
|
|
294
|
+
}
|
|
295
|
+
lines.push(` - ${j.alias} task_id:${j.taskID} agent:${j.agent}${ageLabel}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (terminal.length > 0) {
|
|
299
|
+
lines.push(" Terminal (unreconciled):");
|
|
300
|
+
for (const j of terminal) {
|
|
301
|
+
const summary = j.resultSummary ? ` — ${j.resultSummary.slice(0, 120)}` : "";
|
|
302
|
+
lines.push(` - ${j.alias} task_id:${j.taskID} state:${j.state}${summary}`);
|
|
303
|
+
if (j.contextFiles.length > 0) {
|
|
304
|
+
lines.push(" Context files read:");
|
|
305
|
+
const shown = j.contextFiles.slice(0, 5);
|
|
306
|
+
const rest = j.contextFiles.length - shown.length;
|
|
307
|
+
const rendered = shown.map((f) => `${f.path} (${f.lineCount} lines)`);
|
|
308
|
+
lines.push(` ${rendered.join(", ")}${rest > 0 ? ` (+${rest} more)` : ""}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
lines.push(" → Call reconcileTerminalJobs() to process these results.");
|
|
312
|
+
}
|
|
313
|
+
const injectedIDs = [...this.injectedCompletionsSeen];
|
|
314
|
+
if (injectedIDs.length > 0) {
|
|
315
|
+
lines.push("");
|
|
316
|
+
lines.push("## Injected Background Completions");
|
|
317
|
+
lines.push("The following subagent results were injected into the chat by opencode (duplicated in the job board above). Use `reconcileTerminalJobs` to acknowledge them and prevent double-response.");
|
|
318
|
+
for (const id of injectedIDs.slice(0, 10)) {
|
|
319
|
+
const job = findJobByTaskID(this.jobs, id);
|
|
320
|
+
if (job) {
|
|
321
|
+
lines.push(`- \`${job.alias}\` — ${job.state} — ${job.resultSummary ?? "(no summary)"}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (reusable.length > 0) {
|
|
326
|
+
lines.push(" Reusable Sessions:");
|
|
327
|
+
for (const j of reusable) {
|
|
328
|
+
lines.push(` - ${j.alias} task_id:${j.taskID} agent:${j.agent}`);
|
|
329
|
+
if (j.contextFiles.length > 0) {
|
|
330
|
+
lines.push(" Context files:");
|
|
331
|
+
const shown = j.contextFiles.slice(0, 3);
|
|
332
|
+
const rest = j.contextFiles.length - shown.length;
|
|
333
|
+
const rendered = shown.map((f) => `${f.path} (${f.lineCount} lines)`);
|
|
334
|
+
lines.push(` ${rendered.join(", ")}${rest > 0 ? ` (+${rest} more)` : ""}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (stale.length > 0) {
|
|
339
|
+
lines.push(" Stale Sessions:");
|
|
340
|
+
for (const j of stale) {
|
|
341
|
+
lines.push(` - ${j.alias} task_id:${j.taskID} agent:${j.agent} (restart detected)`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (terminal.length > 0 || running.length > 0) {
|
|
345
|
+
lines.push("", "## Operational Guardrails");
|
|
346
|
+
lines.push("- Do not poll running jobs — wait for hook-driven background completion notifications.");
|
|
347
|
+
lines.push("- Use cancel_task only when the user asks or a running lane becomes obsolete.");
|
|
348
|
+
lines.push("- Reconcile ALL terminal jobs before your final response to the user.");
|
|
349
|
+
lines.push("- Reuse only completed sessions for the same specialist/context — never reuse cancelled or errored ones.");
|
|
350
|
+
}
|
|
351
|
+
lines.push("", "## Summary");
|
|
352
|
+
return lines.join("\n");
|
|
353
|
+
}
|
|
354
|
+
// -----------------------------------------------------------------------
|
|
355
|
+
// Dirty polling (ponytail: 1-byte state-change tracker)
|
|
356
|
+
// -----------------------------------------------------------------------
|
|
357
|
+
isDirty() {
|
|
358
|
+
return this.dirty;
|
|
359
|
+
}
|
|
360
|
+
markClean() {
|
|
361
|
+
this.dirty = false;
|
|
362
|
+
}
|
|
363
|
+
// -----------------------------------------------------------------------
|
|
364
|
+
// Mini status (ponytail: ~20 tokens, injected every round when clean)
|
|
365
|
+
// -----------------------------------------------------------------------
|
|
366
|
+
formatMini(parentSessionID) {
|
|
367
|
+
const running = this.getRunningJobs(parentSessionID);
|
|
368
|
+
const terminal = this.getTerminalUnreconciledJobs(parentSessionID);
|
|
369
|
+
if (running.length === 0 && terminal.length === 0)
|
|
370
|
+
return null;
|
|
371
|
+
let s = `Jobs: ${running.length}r/${terminal.length}u`;
|
|
372
|
+
if (terminal.length > 0)
|
|
373
|
+
s += ` | reconcileTerminalJobs()`;
|
|
374
|
+
return s;
|
|
375
|
+
}
|
|
376
|
+
// -----------------------------------------------------------------------
|
|
377
|
+
// Dedup injected completions
|
|
378
|
+
// -----------------------------------------------------------------------
|
|
379
|
+
isInjectedCompletionProcessed(id) {
|
|
380
|
+
if (this.processedCompletions.has(id))
|
|
381
|
+
return true;
|
|
382
|
+
if (this.processedCompletions.size >= MAX_INJECTED_COMPLETIONS) {
|
|
383
|
+
// Evict oldest half
|
|
384
|
+
const entries = [...this.processedCompletions];
|
|
385
|
+
this.processedCompletions = new Set(entries.slice(entries.length / 2));
|
|
386
|
+
}
|
|
387
|
+
this.processedCompletions.add(id);
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
markInjectedCompletionSeen(taskID) {
|
|
391
|
+
this.injectedCompletionsSeen.add(taskID);
|
|
392
|
+
}
|
|
393
|
+
// -----------------------------------------------------------------------
|
|
394
|
+
// Cleanup
|
|
395
|
+
// -----------------------------------------------------------------------
|
|
396
|
+
dropSession(sessionID) {
|
|
397
|
+
const toDelete = [];
|
|
398
|
+
for (const [key, job] of this.jobs) {
|
|
399
|
+
if (job.taskID === sessionID || job.parentSessionID === sessionID) {
|
|
400
|
+
toDelete.push(key);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (toDelete.length > 0) {
|
|
404
|
+
for (const key of toDelete) {
|
|
405
|
+
this.jobs.delete(key);
|
|
406
|
+
}
|
|
407
|
+
this.dirty = true;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
clear() {
|
|
411
|
+
this.jobs.clear();
|
|
412
|
+
this.pendingCalls = [];
|
|
413
|
+
this.agentCounter.clear();
|
|
414
|
+
this.processedCompletions.clear();
|
|
415
|
+
this.injectedCompletionsSeen.clear();
|
|
416
|
+
this.dirty = true;
|
|
417
|
+
}
|
|
418
|
+
snapshot() {
|
|
419
|
+
return {
|
|
420
|
+
jobs: [...this.jobs.values()],
|
|
421
|
+
pendingCalls: [...this.pendingCalls],
|
|
422
|
+
agentCounter: [...this.agentCounter],
|
|
423
|
+
processedCompletions: [...this.processedCompletions],
|
|
424
|
+
injectedCompletionsSeen: [...this.injectedCompletionsSeen],
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
restore(snapshot) {
|
|
428
|
+
this.clear();
|
|
429
|
+
for (const job of snapshot.jobs ?? []) {
|
|
430
|
+
const restored = { ...job };
|
|
431
|
+
if (restored.state === "running") {
|
|
432
|
+
restored.state = "stale";
|
|
433
|
+
restored.terminalUnreconciled = true;
|
|
434
|
+
}
|
|
435
|
+
this.jobs.set(restored.taskID, restored);
|
|
436
|
+
}
|
|
437
|
+
this.pendingCalls = [...(snapshot.pendingCalls ?? [])];
|
|
438
|
+
this.agentCounter = new Map(snapshot.agentCounter ?? []);
|
|
439
|
+
this.processedCompletions = new Set(snapshot.processedCompletions ?? []);
|
|
440
|
+
this.injectedCompletionsSeen = new Set(snapshot.injectedCompletionsSeen ?? []);
|
|
441
|
+
this.dirty = true;
|
|
442
|
+
}
|
|
443
|
+
get size() {
|
|
444
|
+
return this.jobs.size;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
// Singleton
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
// ponytail: single board for entire plugin lifetime.
|
|
451
|
+
// Upgrade: isolate per worktree or project if concurrent sessions collide.
|
|
452
|
+
export const jobBoard = new BackgroundJobBoard();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Model, UserMessage } from "@opencode-ai/sdk";
|
|
2
|
+
import type { ProviderContext } from "@opencode-ai/plugin";
|
|
3
|
+
import type { LazyRuntime } from "./runtime.js";
|
|
4
|
+
export declare function createChatParamsHook(runtime?: LazyRuntime): (input: {
|
|
5
|
+
sessionID: string;
|
|
6
|
+
agent: string;
|
|
7
|
+
model: Model;
|
|
8
|
+
provider: ProviderContext;
|
|
9
|
+
message: UserMessage;
|
|
10
|
+
}, output: {
|
|
11
|
+
temperature: number;
|
|
12
|
+
topP: number;
|
|
13
|
+
topK: number;
|
|
14
|
+
maxOutputTokens: number | undefined;
|
|
15
|
+
options: Record<string, unknown>;
|
|
16
|
+
}) => Promise<void>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent LLM parameter configuration.
|
|
3
|
+
* Sets temperature and other params based on agent role.
|
|
4
|
+
*
|
|
5
|
+
* ponytail: hardcoded defaults. Designer gets 0.7 for creativity, rest 0.1-0.2.
|
|
6
|
+
*/
|
|
7
|
+
const AGENT_TEMPERATURES = {
|
|
8
|
+
lazy: 0.1,
|
|
9
|
+
"lazy-explorer": 0.1,
|
|
10
|
+
"lazy-oracle": 0.1,
|
|
11
|
+
"lazy-librarian": 0.2,
|
|
12
|
+
"lazy-designer": 0.7,
|
|
13
|
+
"lazy-fixer": 0.2,
|
|
14
|
+
"lazy-observer": 0.1,
|
|
15
|
+
};
|
|
16
|
+
export function createChatParamsHook(runtime) {
|
|
17
|
+
return async (input, output) => {
|
|
18
|
+
const temp = AGENT_TEMPERATURES[input.agent] ?? 0.2;
|
|
19
|
+
output.temperature = temp;
|
|
20
|
+
const map = runtime?.sessionAgentMap;
|
|
21
|
+
if (map) {
|
|
22
|
+
map.set(input.sessionID, input.agent);
|
|
23
|
+
// Prune to prevent memory leaks
|
|
24
|
+
if (map.size > 1000) {
|
|
25
|
+
const firstKey = map.keys().next().value;
|
|
26
|
+
map.delete(firstKey);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deepwork command — heavy multi-phase coding sessions.
|
|
3
|
+
*
|
|
4
|
+
* Activated by: /deepwork <task description>
|
|
5
|
+
*
|
|
6
|
+
* ponytail: command injects deepwork rules directly into the conversation
|
|
7
|
+
* as activation text. No skill file, no two-step activation.
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEEPWORK_ACTIVATION: (task: string) => string;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deepwork command — heavy multi-phase coding sessions.
|
|
3
|
+
*
|
|
4
|
+
* Activated by: /deepwork <task description>
|
|
5
|
+
*
|
|
6
|
+
* ponytail: command injects deepwork rules directly into the conversation
|
|
7
|
+
* as activation text. No skill file, no two-step activation.
|
|
8
|
+
*/
|
|
9
|
+
export const DEEPWORK_ACTIVATION = (task) => `DEEPWORK MODE ACTIVE — heavy multi-phase coding session
|
|
10
|
+
|
|
11
|
+
Task: ${task}
|
|
12
|
+
|
|
13
|
+
## 1. Pre-work Context Gathering
|
|
14
|
+
Before any planning or coding, review existing context thoroughly:
|
|
15
|
+
- Read PRDs, spec issues, GitHub issues — understand the "why" before the "how"
|
|
16
|
+
- Review the existing codebase: current architecture, patterns, conventions
|
|
17
|
+
- Load any related design mockups, Figma links, or visual references
|
|
18
|
+
- Know what's already built and what's net-new
|
|
19
|
+
|
|
20
|
+
## 2. Plan Loading
|
|
21
|
+
- Multi-file read with Read tool to bring all relevant files into context
|
|
22
|
+
- Summarize findings in the deepwork file before starting implementation
|
|
23
|
+
- Get @lazy-oracle review of the plan before Phase 1 execution begins
|
|
24
|
+
|
|
25
|
+
## 3. Designer Handoff Discipline
|
|
26
|
+
When @lazy-designer delivers UI/UX components:
|
|
27
|
+
- @lazy-designer must explain every layout, spacing, color, typography decision
|
|
28
|
+
- Use CSS comments to annotate design rationale (not just what, but WHY)
|
|
29
|
+
- NO merge-squashing of designer output — cherry-pick: one component file at a time
|
|
30
|
+
- Preserve design intent across later phases; @lazy-fixer only does mechanical follow-up
|
|
31
|
+
- If a later phase must alter design, flag it to @lazy-designer for re-review
|
|
32
|
+
|
|
33
|
+
## 4. Multiple Parallel Lanes
|
|
34
|
+
- Launch two @lazy-designer lanes with different aesthetic philosophies when ambiguity exists
|
|
35
|
+
- Each lane gets its own deepwork tracking slug: \`.lazy/deepwork/<task>-<lanename>.md\`
|
|
36
|
+
- Compare lanes independently; @lazy-oracle picks the winning lane before merging
|
|
37
|
+
|
|
38
|
+
## 5. Progress Tracking
|
|
39
|
+
- Create \`.lazy/deepwork/<slug>.md\` — track goals, plans, oracle reviews, phases, blockers
|
|
40
|
+
- Reference files by path, not content. Keep out of git (\`.lazy/\` is gitignored)
|
|
41
|
+
- Update after every phase: what was done, what was reviewed, what's next
|
|
42
|
+
- Mark each phase as ✓ COMPLETE or ⚠ BLOCKED
|
|
43
|
+
|
|
44
|
+
## 6. Self-Critique After Each Check-in
|
|
45
|
+
After every check-in (commit, phase completion, designer delivery):
|
|
46
|
+
- @lazy-oracle: found issues → fix actionable ones immediately before continuing
|
|
47
|
+
- @lazy/review: scan the diff for bugs, unnecessary complexity, deviations from plan
|
|
48
|
+
- Add self-critique notes to the deepwork file — what went well, what could be tighter
|
|
49
|
+
- Non-actionable critique becomes ponytail debt (note it, move on)
|
|
50
|
+
|
|
51
|
+
## Exit Discipline
|
|
52
|
+
- All phases ✓ COMPLETE before declaring the session done
|
|
53
|
+
- Final @lazy-oracle review passes with no blocking issues
|
|
54
|
+
- Deepwork file archived as session record
|
|
55
|
+
- Wait for hook-driven background completion before consuming results`;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error recovery hook (tool.execute.after).
|
|
3
|
+
* Full slim-equivalent pattern set:
|
|
4
|
+
* 1. JSON parse error recovery (8+ patterns)
|
|
5
|
+
* 2. Apply-patch failure with structured guidance
|
|
6
|
+
* 3. Task delegate retry guidance (8 error patterns)
|
|
7
|
+
* 4. Post-file-tool nudge (phase reminder for lazy primary read/write)
|
|
8
|
+
*/
|
|
9
|
+
interface ToolAfterInput {
|
|
10
|
+
tool: string;
|
|
11
|
+
sessionID: string;
|
|
12
|
+
callID: string;
|
|
13
|
+
args: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
interface ToolAfterOutput {
|
|
16
|
+
title: string;
|
|
17
|
+
output: string;
|
|
18
|
+
metadata: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
export declare function createErrorRecoveryHook(runtime?: import("./runtime.js").LazyRuntime): (input: ToolAfterInput, output: ToolAfterOutput) => void;
|
|
21
|
+
export {};
|