@lawaty/lacode 0.8.0 → 0.10.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/CHANGELOG.md +164 -2
- package/README.md +10 -7
- package/dist/agents.js +70 -11
- package/dist/commands.js +6 -3
- package/dist/index.js +329 -60
- package/dist/lacode.js +83 -23
- package/dist/pool.js +4 -4
- package/dist/tunnel.js +69 -20
- package/docs/CONTEXT.md +21 -6
- package/docs/OMNIROUTE.md +2 -2
- package/docs/TUNNEL.md +86 -16
- package/docs/USER-STORIES.md +9 -3
- package/package.json +1 -1
- package/presets/README.md +4 -4
- package/presets/free-tier.json +23 -7
package/dist/index.js
CHANGED
|
@@ -51,10 +51,10 @@ var USER_CONFIG_DIR = join2(homedir(), ".config", "lacode");
|
|
|
51
51
|
var USER_MODELS_PATH = join2(USER_CONFIG_DIR, "pool.json");
|
|
52
52
|
var USER_MODELS_TXT = join2(USER_CONFIG_DIR, "pool.txt");
|
|
53
53
|
var FALLBACK_SLOTS = [
|
|
54
|
-
{ index: 1, model: "opencode/
|
|
55
|
-
{ index: 2, model: "opencode-go/
|
|
56
|
-
{ index: 3, model: "opencode/
|
|
57
|
-
{ index: 4, model: "opencode/
|
|
54
|
+
{ index: 1, model: "opencode/big-pickle", weight: 3 },
|
|
55
|
+
{ index: 2, model: "opencode-go/minimax-m3.1-flash-free", weight: 2 },
|
|
56
|
+
{ index: 3, model: "opencode/deepseek-v4.1-flash-free", weight: 1 },
|
|
57
|
+
{ index: 4, model: "opencode/glm-5.3-flash-free", weight: 1 }
|
|
58
58
|
];
|
|
59
59
|
function parseSlots(parsed, origin) {
|
|
60
60
|
const problems = [];
|
|
@@ -786,8 +786,8 @@ failing, or a runtime message saying no slot is available \u2014 that is not a r
|
|
|
786
786
|
to do the work yourself, and not a reason to stop and ask.
|
|
787
787
|
|
|
788
788
|
**Retry.** A refused or cooling slot is transient and costs you nothing but a
|
|
789
|
-
turn. Spawn the bare base again. Do not escalate a
|
|
790
|
-
|
|
789
|
+
turn. Spawn the bare base again. Do not escalate to a numbered variant after a
|
|
790
|
+
refusal \u2014 the refusal means the pool has a routing problem, not that you
|
|
791
791
|
picked the wrong number.
|
|
792
792
|
|
|
793
793
|
Retry is always available and never wrong, which is why it is the default. But
|
|
@@ -1146,7 +1146,7 @@ if the blocker is understanding rather than execution, report that instead.`,
|
|
|
1146
1146
|
}
|
|
1147
1147
|
},
|
|
1148
1148
|
"context": {
|
|
1149
|
-
"description": "Repository cartographer and memory maintainer. Bootstraps and incrementally maintains .opencode/context/ (architecture, contexts, conventions, workflows, decisions) as a small navigation map for future agents. Runs automatically after sessions that changed files; can also be invoked with /context-init, /context-update, /context-review. Never use it for general coding tasks.",
|
|
1149
|
+
"description": "Repository cartographer and memory maintainer. Bootstraps and incrementally maintains .opencode/context/ (architecture, contexts, conventions, workflows, decisions) as a small navigation map for future agents, and keeps .opencode/context/active.md \u2014 the active backlog of work in flight, grouped by week. Runs automatically after sessions that changed files; can also be invoked with /context-init, /context-update, /context-review. Never use it for general coding tasks.",
|
|
1150
1150
|
"mode": "subagent",
|
|
1151
1151
|
"temperature": 0.1,
|
|
1152
1152
|
prompt: `# Role
|
|
@@ -1162,7 +1162,7 @@ map is a derived index of it.
|
|
|
1162
1162
|
|
|
1163
1163
|
# Owned files
|
|
1164
1164
|
|
|
1165
|
-
You own exactly these
|
|
1165
|
+
You own exactly these files and nothing else:
|
|
1166
1166
|
|
|
1167
1167
|
- \`.opencode/context/architecture.md\` \u2014 high-level architecture, major layers,
|
|
1168
1168
|
boundaries, important entry points, dependency rules. Target 50\u2013150 lines.
|
|
@@ -1177,6 +1177,9 @@ You own exactly these five files and nothing else:
|
|
|
1177
1177
|
- \`.opencode/context/decisions.md\` \u2014 architectural decisions discovered
|
|
1178
1178
|
during work that are not already recorded in the project's own decision
|
|
1179
1179
|
log. Concise; variable length.
|
|
1180
|
+
- \`.opencode/context/active.md\` \u2014 the active backlog: work in flight, grouped
|
|
1181
|
+
by the week it was opened. Not part of the navigation map; it is described
|
|
1182
|
+
under "Task backlog" below.
|
|
1180
1183
|
|
|
1181
1184
|
# Current-state only
|
|
1182
1185
|
|
|
@@ -1216,7 +1219,8 @@ Explore progressively and stop as soon as you have a usable map:
|
|
|
1216
1219
|
You are mapping a city, not auditing every house. Do not attempt to
|
|
1217
1220
|
understand every file.
|
|
1218
1221
|
|
|
1219
|
-
Then write the five files.
|
|
1222
|
+
Then write the five files. Also create \`.opencode/context/active.md\` with its
|
|
1223
|
+
heading and no tasks. For each significant subsystem, give a short
|
|
1220
1224
|
"Start here" list \u2014 at most about five canonical files, each with a one-phrase
|
|
1221
1225
|
reason. This is the single most valuable thing you produce: where an agent
|
|
1222
1226
|
should open its editor next.
|
|
@@ -1240,7 +1244,7 @@ Trigger: \`.opencode/context/\` exists and work has just been done here.
|
|
|
1240
1244
|
- an architectural decision
|
|
1241
1245
|
- a new dependency between subsystems
|
|
1242
1246
|
- a renamed or relocated subsystem
|
|
1243
|
-
|
|
1247
|
+
- a previously undocumented invariant
|
|
1244
1248
|
|
|
1245
1249
|
Do not update documentation merely because source files changed. Do not
|
|
1246
1250
|
rewrite files wholesale. Do not make timestamp-only edits. Do not add a fact
|
|
@@ -1248,6 +1252,38 @@ that is already stated in another context file \u2014 one fact has one home, and
|
|
|
1248
1252
|
the other files link to it. If nothing material changed, make no edits and
|
|
1249
1253
|
report "no update needed".
|
|
1250
1254
|
|
|
1255
|
+
Also keep \`.opencode/context/active.md\` current: record work that is in
|
|
1256
|
+
flight, and mark a task finished \u2014 with the date it finished \u2014 when it is.
|
|
1257
|
+
|
|
1258
|
+
# Task backlog
|
|
1259
|
+
|
|
1260
|
+
\`.opencode/context/active.md\` holds the work in flight. It is a rolling
|
|
1261
|
+
window, not a history: it answers "what is open, and what just finished".
|
|
1262
|
+
|
|
1263
|
+
Format \u2014 one line per task, under the ISO week the task was opened in:
|
|
1264
|
+
|
|
1265
|
+
## 2026-W43
|
|
1266
|
+
|
|
1267
|
+
- [ ] Ship the release notes \u2014 opened 2026-W43
|
|
1268
|
+
- [x] Fix the flaky test \u2014 opened 2026-W43, finished 2026-10-19
|
|
1269
|
+
|
|
1270
|
+
The rules:
|
|
1271
|
+
|
|
1272
|
+
- A task that is not marked \`[x]\` is OPEN. An open task is never removed,
|
|
1273
|
+
however old it is.
|
|
1274
|
+
- A task is FINISHED when it is marked \`[x]\` AND carries a \`finished\`
|
|
1275
|
+
date. Record the date it actually finished, as \`YYYY-MM-DD\`.
|
|
1276
|
+
- The week is \`YYYY-Www\`, Monday to Sunday. \`date +%G-W%V\` prints it.
|
|
1277
|
+
- A finished task is dropped once it is more than a week old. The plugin
|
|
1278
|
+
enforces that: record the finished task with its date and let the window
|
|
1279
|
+
close on its own. Do not delete old finished tasks by hand, and do not
|
|
1280
|
+
keep a finished task back to preserve it \u2014 the file is meant to stay
|
|
1281
|
+
small.
|
|
1282
|
+
- Never invent a task. Record work that actually happened, and mark a task
|
|
1283
|
+
finished only once it is finished.
|
|
1284
|
+
- Keep the file inside the window. If the backlog has grown past a handful
|
|
1285
|
+
of weeks, that is a signal to close finished tasks, not to add sections.
|
|
1286
|
+
|
|
1251
1287
|
# Minimal exploration
|
|
1252
1288
|
|
|
1253
1289
|
Default shape:
|
|
@@ -1334,7 +1370,7 @@ the lowest-value ones, and link out to authoritative project docs.
|
|
|
1334
1370
|
|
|
1335
1371
|
Trigger: an explicit review (see the \`context-review\` command).
|
|
1336
1372
|
|
|
1337
|
-
Audit the
|
|
1373
|
+
Audit the map files and the backlog for:
|
|
1338
1374
|
|
|
1339
1375
|
- stale information \u2014 verify claims against the code
|
|
1340
1376
|
- contradictions between files, or between the map and the code
|
|
@@ -1342,10 +1378,12 @@ Audit the five files for:
|
|
|
1342
1378
|
- duplicated information \u2014 one canonical place per fact
|
|
1343
1379
|
- missing canonical entry points for significant subsystems
|
|
1344
1380
|
- unsupported assumptions \u2014 anything unmarked that you cannot verify
|
|
1381
|
+
- backlog tasks that are finished but carry no date \u2014 the retention window
|
|
1382
|
+
cannot age those out, so they stay forever
|
|
1345
1383
|
|
|
1346
|
-
Fix problems only in the
|
|
1347
|
-
|
|
1348
|
-
|
|
1384
|
+
Fix problems only in the files you own. Never modify source code during a
|
|
1385
|
+
review. Report what you found, what you fixed, and what you could not
|
|
1386
|
+
verify.
|
|
1349
1387
|
|
|
1350
1388
|
# Reporting
|
|
1351
1389
|
|
|
@@ -1513,7 +1551,28 @@ function buildAgentSet() {
|
|
|
1513
1551
|
const variant = {
|
|
1514
1552
|
...def,
|
|
1515
1553
|
description: `Hidden pool variant ${base}-${slot}: slot ${slot}. Its model is NOT set here -- LaCode injects it from the resolved pool preset at config time, so the preset is the only place a pool model is written down. Spawn '${base}' and let the pool route.`,
|
|
1516
|
-
hidden: true
|
|
1554
|
+
hidden: true,
|
|
1555
|
+
// `mode: "primary"` is what keeps a variant OUT of the model's system
|
|
1556
|
+
// prompt. `hidden` cannot do it.
|
|
1557
|
+
//
|
|
1558
|
+
// opencode builds the "Available agent types" block by filtering the
|
|
1559
|
+
// agent registry on `mode !== "primary"` and on the caller's task
|
|
1560
|
+
// permission. It never looks at `hidden`: that flag is honoured only by
|
|
1561
|
+
// the TUI and the @-mention pickers, by the "agent not found" message,
|
|
1562
|
+
// and by default-agent resolution. A variant left as `mode: "subagent"`
|
|
1563
|
+
// is therefore listed to the model under its own name -- "explore-1:
|
|
1564
|
+
// Hidden pool variant explore-1" -- and the model copies that name
|
|
1565
|
+
// straight into its next spawn. The pool then refuses it, because
|
|
1566
|
+
// naming a variant is how a caller would pick the provider and skip the
|
|
1567
|
+
// rotation. The refusal is right; the leak that provokes it is not.
|
|
1568
|
+
//
|
|
1569
|
+
// `mode` is the only field that filter respects, and setting it here is
|
|
1570
|
+
// safe: the task tool never reads an agent's mode -- it resolves the
|
|
1571
|
+
// agent by name and runs it -- and every other place that filters on
|
|
1572
|
+
// `mode !== "primary"` also filters on `!hidden`, which these already
|
|
1573
|
+
// are. opencode uses this same pair for its own compaction, title and
|
|
1574
|
+
// summary agents.
|
|
1575
|
+
mode: "primary"
|
|
1517
1576
|
};
|
|
1518
1577
|
delete variant.model;
|
|
1519
1578
|
set[`${base}-${slot}`] = variant;
|
|
@@ -1553,7 +1612,8 @@ your system prompt.
|
|
|
1553
1612
|
Otherwise follow the bootstrap procedure in your system prompt: progressive
|
|
1554
1613
|
exploration (README \u2192 existing docs \u2192 manifests \u2192 top-level structure \u2192 build
|
|
1555
1614
|
and framework config \u2192 entry points \u2192 selective source), then write the five
|
|
1556
|
-
context files inside their size targets.
|
|
1615
|
+
context files inside their size targets. Also create
|
|
1616
|
+
\`.opencode/context/active.md\` with its heading and no tasks.
|
|
1557
1617
|
|
|
1558
1618
|
Remember: write conclusions, not inventories. Link to the project's existing
|
|
1559
1619
|
documentation instead of duplicating it.
|
|
@@ -1590,10 +1650,10 @@ timestamp-only edits. If nothing material changed, make no edits and report
|
|
|
1590
1650
|
subtask: true,
|
|
1591
1651
|
template: `Context review.
|
|
1592
1652
|
|
|
1593
|
-
Everything outside
|
|
1653
|
+
Everything outside \`.opencode/context/\` is read-only for this run. Do not
|
|
1594
1654
|
modify source code under any circumstances.
|
|
1595
1655
|
|
|
1596
|
-
Audit the
|
|
1656
|
+
Audit the context files and the backlog for:
|
|
1597
1657
|
|
|
1598
1658
|
- stale information \u2014 verify claims against the actual code
|
|
1599
1659
|
- contradictions between context files, or between the map and the code
|
|
@@ -1604,6 +1664,8 @@ Audit the five context files for:
|
|
|
1604
1664
|
- missing canonical entry points for significant subsystems
|
|
1605
1665
|
- unsupported assumptions \u2014 anything stated as fact that you cannot verify,
|
|
1606
1666
|
or that lacks a confidence marker
|
|
1667
|
+
- backlog tasks that are finished but carry no date \u2014 the retention window
|
|
1668
|
+
cannot age those out, so they stay forever
|
|
1607
1669
|
|
|
1608
1670
|
Fix what you find in \`.opencode/context/\` only, then report: findings, fixes,
|
|
1609
1671
|
and anything you could not verify.`
|
|
@@ -2096,12 +2158,11 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
|
|
|
2096
2158
|
if (typeof requested !== "string") return;
|
|
2097
2159
|
const manual = manualVariant(requested);
|
|
2098
2160
|
if (manual) {
|
|
2099
|
-
await log("warn", `refused manual spawn of slot variant
|
|
2100
|
-
variant: requested,
|
|
2161
|
+
await log("warn", `refused manual spawn of a pool slot variant; spawn "${manual}" instead`, {
|
|
2101
2162
|
base: manual
|
|
2102
2163
|
});
|
|
2103
2164
|
throw new Error(
|
|
2104
|
-
|
|
2165
|
+
`That agent is a pool slot variant and cannot be spawned by name. Naming it lets the caller pick the provider: it skips the round-robin rotation and the slot's ceiling, so one provider takes every concurrent claim. Spawn "${manual}" and let the pool choose the slot.`
|
|
2105
2166
|
);
|
|
2106
2167
|
}
|
|
2107
2168
|
try {
|
|
@@ -2226,10 +2287,110 @@ round robin in slot order, each slot taking as many turns as its weight says, so
|
|
|
2226
2287
|
});
|
|
2227
2288
|
|
|
2228
2289
|
// src/plugins/context-autoupdate.ts
|
|
2229
|
-
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync as readdirSync2, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
2290
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync as readdirSync2, readFileSync as readFileSync4, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
2230
2291
|
import { homedir as homedir2 } from "node:os";
|
|
2231
2292
|
import * as path2 from "node:path";
|
|
2232
2293
|
|
|
2294
|
+
// src/lib/active-backlog.ts
|
|
2295
|
+
var ACTIVE_BACKLOG_FILE = "active.md";
|
|
2296
|
+
var RETENTION_DAYS = 7;
|
|
2297
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
2298
|
+
var HEADER = `# Active backlog
|
|
2299
|
+
|
|
2300
|
+
Tasks in flight, grouped by the week they were opened. A finished task stays
|
|
2301
|
+
here for a week after it was finished and is then dropped; an open task stays
|
|
2302
|
+
however long it takes. Add a line under the right week, and give a finished
|
|
2303
|
+
task its date: \`- [x] Title \u2014 opened 2026-W43, finished 2026-10-19\`.`;
|
|
2304
|
+
var TASK_LINE = /^\s*(?:[-*+]|\d+[.)])\s*\[([ xX])\]\s*(.*)$/;
|
|
2305
|
+
var META = /(?:—|–|-)\s*opened\s+(\d{4}-W(?:0[1-9]|[1-4]\d|5[0-3]))|[,—–-]\s*finished\s+(\d{4}-\d{2}-\d{2})/gi;
|
|
2306
|
+
var isCalendarDate = (s) => {
|
|
2307
|
+
const at = Date.parse(`${s}T00:00:00Z`);
|
|
2308
|
+
return Number.isFinite(at) && new Date(at).toISOString().slice(0, 10) === s;
|
|
2309
|
+
};
|
|
2310
|
+
var parseBacklog = (text) => {
|
|
2311
|
+
const entries = [];
|
|
2312
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
2313
|
+
const line = TASK_LINE.exec(raw);
|
|
2314
|
+
if (!line) continue;
|
|
2315
|
+
const finished = line[1] !== " ";
|
|
2316
|
+
let week;
|
|
2317
|
+
let finishedAt;
|
|
2318
|
+
const stripped = line[2].replace(META, (fragment, openedWeek, finishedDate) => {
|
|
2319
|
+
if (openedWeek !== void 0) {
|
|
2320
|
+
week ??= openedWeek.toUpperCase();
|
|
2321
|
+
return "";
|
|
2322
|
+
}
|
|
2323
|
+
if (finishedDate !== void 0 && isCalendarDate(finishedDate)) {
|
|
2324
|
+
finishedAt ??= finishedDate;
|
|
2325
|
+
return "";
|
|
2326
|
+
}
|
|
2327
|
+
return fragment;
|
|
2328
|
+
});
|
|
2329
|
+
const title = stripped.replace(/^[\s—–-]+|[\s—–-]+$/g, "");
|
|
2330
|
+
if (!title) continue;
|
|
2331
|
+
entries.push({ title, ...week ? { week } : {}, finished, ...finishedAt ? { finishedAt } : {} });
|
|
2332
|
+
}
|
|
2333
|
+
return entries;
|
|
2334
|
+
};
|
|
2335
|
+
var ISO_WEEK = /^(\d{4})-W(\d{2})$/i;
|
|
2336
|
+
var newestFirst = (a, b) => {
|
|
2337
|
+
const ma = ISO_WEEK.exec(a);
|
|
2338
|
+
const mb = ISO_WEEK.exec(b);
|
|
2339
|
+
if (ma && mb) return Number(mb[1]) - Number(ma[1]) || Number(mb[2]) - Number(ma[2]);
|
|
2340
|
+
if (ma) return -1;
|
|
2341
|
+
if (mb) return 1;
|
|
2342
|
+
return a < b ? 1 : a > b ? -1 : 0;
|
|
2343
|
+
};
|
|
2344
|
+
var renderLine = (entry) => {
|
|
2345
|
+
const box = entry.finished ? "[x]" : "[ ]";
|
|
2346
|
+
const meta = [];
|
|
2347
|
+
if (entry.week) meta.push(`opened ${entry.week}`);
|
|
2348
|
+
if (entry.finished && entry.finishedAt) meta.push(`finished ${entry.finishedAt}`);
|
|
2349
|
+
const head = `- ${box} ${entry.title}`;
|
|
2350
|
+
if (meta.length === 0) return head;
|
|
2351
|
+
return entry.week ? `${head} \u2014 ${meta.join(", ")}` : `${head}, ${meta.join(", ")}`;
|
|
2352
|
+
};
|
|
2353
|
+
var renderBacklog = (entries) => {
|
|
2354
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
2355
|
+
for (const entry of entries) {
|
|
2356
|
+
const key = entry.week ? entry.week : null;
|
|
2357
|
+
const bucket = buckets.get(key);
|
|
2358
|
+
if (bucket) bucket.push(entry);
|
|
2359
|
+
else buckets.set(key, [entry]);
|
|
2360
|
+
}
|
|
2361
|
+
const weeks = [...buckets.keys()].filter((week) => week !== null).sort(newestFirst);
|
|
2362
|
+
let out = HEADER;
|
|
2363
|
+
for (const week of weeks) out += `
|
|
2364
|
+
|
|
2365
|
+
## ${week}
|
|
2366
|
+
|
|
2367
|
+
${buckets.get(week).map(renderLine).join("\n")}`;
|
|
2368
|
+
const unscheduled = buckets.get(null);
|
|
2369
|
+
if (unscheduled) out += `
|
|
2370
|
+
|
|
2371
|
+
## Unscheduled
|
|
2372
|
+
|
|
2373
|
+
${unscheduled.map(renderLine).join("\n")}`;
|
|
2374
|
+
return `${out}
|
|
2375
|
+
`;
|
|
2376
|
+
};
|
|
2377
|
+
var normalizeBacklog = (text, now = Date.now()) => {
|
|
2378
|
+
const entries = parseBacklog(text);
|
|
2379
|
+
const kept = [];
|
|
2380
|
+
const removed = [];
|
|
2381
|
+
for (const entry of entries) {
|
|
2382
|
+
if (entry.finished && entry.finishedAt) {
|
|
2383
|
+
const at = Date.parse(`${entry.finishedAt}T00:00:00Z`);
|
|
2384
|
+
if (Number.isFinite(at) && now - at > RETENTION_DAYS * DAY_MS) {
|
|
2385
|
+
removed.push(entry.title);
|
|
2386
|
+
continue;
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
kept.push(entry);
|
|
2390
|
+
}
|
|
2391
|
+
return { text: renderBacklog(kept), removed };
|
|
2392
|
+
};
|
|
2393
|
+
|
|
2233
2394
|
// src/lib/jsonc.ts
|
|
2234
2395
|
var stripJsonc = (raw) => {
|
|
2235
2396
|
let out = "";
|
|
@@ -2437,7 +2598,7 @@ var changedSince = (before, dir) => {
|
|
|
2437
2598
|
};
|
|
2438
2599
|
var RETAIN_TRANSCRIPTS = 20;
|
|
2439
2600
|
var transcriptDir = () => process.env.LACODE_TRANSCRIPT_DIR ?? path2.join(homedir2(), ".local", "share", "opencode", "lacode", "cartographer");
|
|
2440
|
-
var retireSession = async (client, directory, sessionID, mode) => {
|
|
2601
|
+
var retireSession = async (client, directory, sessionID, mode, log) => {
|
|
2441
2602
|
try {
|
|
2442
2603
|
const transcript = await client.session.messages({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0);
|
|
2443
2604
|
const dir = transcriptDir();
|
|
@@ -2446,8 +2607,18 @@ var retireSession = async (client, directory, sessionID, mode) => {
|
|
|
2446
2607
|
const file = path2.join(dir, `${stamp}-${mode}.json`);
|
|
2447
2608
|
writeFileSync2(file, JSON.stringify({ sessionID, mode, messages: transcript ?? null }, null, 2));
|
|
2448
2609
|
pruneTranscripts(dir);
|
|
2610
|
+
const gone = async () => await client.session.get({ path: { id: sessionID }, query: { directory } }).then((r) => r.data).catch(() => void 0) === void 0;
|
|
2449
2611
|
await client.session.delete({ path: { id: sessionID }, query: { directory } }).catch(() => void 0);
|
|
2450
|
-
|
|
2612
|
+
if (await gone()) return;
|
|
2613
|
+
await new Promise((resolve3) => setTimeout(resolve3, 500));
|
|
2614
|
+
await client.session.delete({ path: { id: sessionID }, query: { directory } }).catch(() => void 0);
|
|
2615
|
+
if (await gone()) return;
|
|
2616
|
+
await log?.("warn", "the cartographer's session could not be removed from the session list; it stays as an empty entry", {
|
|
2617
|
+
sessionID,
|
|
2618
|
+
mode
|
|
2619
|
+
});
|
|
2620
|
+
} catch (error) {
|
|
2621
|
+
await log?.("warn", `could not retire the cartographer's session: ${String(error)}`, { sessionID, mode });
|
|
2451
2622
|
}
|
|
2452
2623
|
};
|
|
2453
2624
|
var pruneTranscripts = (dir) => {
|
|
@@ -2471,6 +2642,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
2471
2642
|
});
|
|
2472
2643
|
const root = path2.resolve(directory);
|
|
2473
2644
|
const contextAbs = path2.join(root, CONTEXT_DIR2);
|
|
2645
|
+
const activeBacklogPath = path2.join(contextAbs, ACTIVE_BACKLOG_FILE);
|
|
2474
2646
|
const config = findProjectConfig();
|
|
2475
2647
|
const writerProblem = config ? verifyWriterRule(config, contextAbs, root) : void 0;
|
|
2476
2648
|
if (writerProblem) void log("error", writerProblem);
|
|
@@ -2480,6 +2652,23 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
2480
2652
|
if (absolute === contextAbs || absolute.startsWith(contextAbs + path2.sep)) return false;
|
|
2481
2653
|
return !toPosix2(absolute).split("/").some((segment) => IGNORED_SEGMENTS.has(segment));
|
|
2482
2654
|
};
|
|
2655
|
+
const normalizeActiveBacklog = async () => {
|
|
2656
|
+
try {
|
|
2657
|
+
if (!existsSync2(activeBacklogPath)) return { removed: 0 };
|
|
2658
|
+
const before = readFileSync4(activeBacklogPath, "utf8");
|
|
2659
|
+
const { text, removed } = normalizeBacklog(before);
|
|
2660
|
+
if (text !== before) writeFileSync2(activeBacklogPath, text);
|
|
2661
|
+
if (removed.length > 0) {
|
|
2662
|
+
await log("info", `active backlog: dropped ${removed.length} task(s) finished more than a week ago`, {
|
|
2663
|
+
removed: removed.length
|
|
2664
|
+
});
|
|
2665
|
+
}
|
|
2666
|
+
return { removed: removed.length };
|
|
2667
|
+
} catch {
|
|
2668
|
+
return { removed: 0 };
|
|
2669
|
+
}
|
|
2670
|
+
};
|
|
2671
|
+
void normalizeActiveBacklog();
|
|
2483
2672
|
const listed = (files) => {
|
|
2484
2673
|
const shown = files.slice(0, MAX_FILES_LISTED);
|
|
2485
2674
|
const rest = files.length - shown.length;
|
|
@@ -2497,7 +2686,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
2497
2686
|
"",
|
|
2498
2687
|
NO_BULK_READS,
|
|
2499
2688
|
"",
|
|
2500
|
-
"Follow the bootstrap procedure in your system prompt: progressive exploration, then write the five context files within their size targets. When the project already has architecture or decision documentation, link to it instead of duplicating it. Write conclusions, not inventories."
|
|
2689
|
+
"Follow the bootstrap procedure in your system prompt: progressive exploration, then write the five context files within their size targets. Also create `.opencode/context/active.md` with its heading and no tasks. When the project already has architecture or decision documentation, link to it instead of duplicating it. Write conclusions, not inventories."
|
|
2501
2690
|
].join("\n") : [
|
|
2502
2691
|
"Incremental context update. Project root: " + directory,
|
|
2503
2692
|
"",
|
|
@@ -2506,13 +2695,18 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
2506
2695
|
"",
|
|
2507
2696
|
NO_BULK_READS,
|
|
2508
2697
|
"",
|
|
2509
|
-
'Follow the incremental-update procedure in your system prompt. Update a context file only if this work introduced or revealed information that will materially help future agents: a new boundary or module, a new or moved canonical entry point, a changed workflow, a new convention, an architectural decision, a new dependency between subsystems, a renamed or moved subsystem, or a previously undocumented invariant. Do not edit documentation merely because source files changed. If nothing material changed, make no edits and report "no update needed".'
|
|
2698
|
+
'Follow the incremental-update procedure in your system prompt. Update a context file only if this work introduced or revealed information that will materially help future agents: a new boundary or module, a new or moved canonical entry point, a changed workflow, a new convention, an architectural decision, a new dependency between subsystems, a renamed or moved subsystem, or a previously undocumented invariant. Do not edit documentation merely because source files changed. If nothing material changed, make no edits and report "no update needed". Also keep `.opencode/context/active.md` current: record work that is in flight, and mark a task finished \u2014 with the date it finished \u2014 when it is.'
|
|
2510
2699
|
].join("\n");
|
|
2511
2700
|
return {
|
|
2512
2701
|
event: async ({ event }) => {
|
|
2513
2702
|
try {
|
|
2514
2703
|
if (event.type === "file.edited") {
|
|
2515
|
-
|
|
2704
|
+
const file = event.properties.file;
|
|
2705
|
+
if (path2.resolve(root, file) === activeBacklogPath) {
|
|
2706
|
+
await normalizeActiveBacklog();
|
|
2707
|
+
return;
|
|
2708
|
+
}
|
|
2709
|
+
if (isTracked(file)) pending.add(file);
|
|
2516
2710
|
return;
|
|
2517
2711
|
}
|
|
2518
2712
|
if (event.type !== "session.idle") return;
|
|
@@ -2551,22 +2745,36 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
2551
2745
|
return;
|
|
2552
2746
|
}
|
|
2553
2747
|
const claimKey2 = claimKey(target, "context-autoupdate");
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2748
|
+
let claimed = false;
|
|
2749
|
+
let before;
|
|
2750
|
+
let started;
|
|
2751
|
+
try {
|
|
2752
|
+
acquire(state2, "context", slot, target, claimKey2, target);
|
|
2753
|
+
claimed = true;
|
|
2754
|
+
await log("info", `borrowed pool slot ${slot.index} (${slot.model}, w${slot.weight}) for auto ${mode}`, {
|
|
2755
|
+
slot: slot.index,
|
|
2756
|
+
weight: slot.weight,
|
|
2757
|
+
model: slot.model
|
|
2758
|
+
});
|
|
2759
|
+
lastRun = Date.now();
|
|
2760
|
+
owned.set(target, Date.now());
|
|
2761
|
+
before = snapshotMap(contextAbs);
|
|
2762
|
+
started = client.session.prompt({
|
|
2763
|
+
path: { id: target },
|
|
2764
|
+
query: { directory },
|
|
2765
|
+
body: { agent, parts: [{ type: "text", text: compose(mode, files) }] }
|
|
2766
|
+
});
|
|
2767
|
+
} catch (error) {
|
|
2768
|
+
if (claimed) release(state2, claimKey2);
|
|
2769
|
+
owned.delete(target);
|
|
2770
|
+
await retireSession(client, directory, target, mode, log);
|
|
2771
|
+
requeue();
|
|
2772
|
+
await log("error", `auto ${mode} could not start: ${String(error)}`);
|
|
2773
|
+
return;
|
|
2774
|
+
}
|
|
2775
|
+
started.then(async (result) => {
|
|
2568
2776
|
release(state2, claimKey2);
|
|
2569
|
-
await retireSession(client, directory, target, mode);
|
|
2777
|
+
await retireSession(client, directory, target, mode, log);
|
|
2570
2778
|
const failure = result.error ?? result.data?.info?.error;
|
|
2571
2779
|
if (failure) {
|
|
2572
2780
|
requeue();
|
|
@@ -2590,7 +2798,7 @@ var contextAutoUpdateHooks = (async ({ client, directory }, options) => {
|
|
|
2590
2798
|
}
|
|
2591
2799
|
}).catch(async (error) => {
|
|
2592
2800
|
release(state2, claimKey2);
|
|
2593
|
-
await retireSession(client, directory, target, mode);
|
|
2801
|
+
await retireSession(client, directory, target, mode, log);
|
|
2594
2802
|
requeue();
|
|
2595
2803
|
await log("error", `auto ${mode} failed: ${String(error)}`);
|
|
2596
2804
|
});
|
|
@@ -2624,6 +2832,8 @@ var helperArgv = (helper, title, message, urgency, icon, sound) => {
|
|
|
2624
2832
|
const name = helper.split(/[\\/]/).pop() ?? "";
|
|
2625
2833
|
return name === "lacode" || name === "lacode.js" ? ["notify", title, message, urgency, icon, sound] : [title, message, urgency, icon, sound];
|
|
2626
2834
|
};
|
|
2835
|
+
var PERMISSION_REPLIES = ["once", "always", "reject"];
|
|
2836
|
+
var REQUEST_ID_ENV = "LACODE_REQUEST_ID";
|
|
2627
2837
|
var safeStringify = (value) => {
|
|
2628
2838
|
try {
|
|
2629
2839
|
return JSON.stringify(value) ?? void 0;
|
|
@@ -2693,12 +2903,16 @@ var shouldNotify = (input) => {
|
|
|
2693
2903
|
if (outcome === "done" && input.awaitingResponse) {
|
|
2694
2904
|
return { notify: false, reason: "awaiting your response" };
|
|
2695
2905
|
}
|
|
2696
|
-
|
|
2906
|
+
const notification = {
|
|
2697
2907
|
notify: true,
|
|
2698
2908
|
title: `opencode \u2014 ${title}`,
|
|
2699
2909
|
message: notifyMessage(outcome, detail),
|
|
2700
2910
|
urgency: outcome === "error" ? "critical" : "normal"
|
|
2701
2911
|
};
|
|
2912
|
+
if (outcome === "permission" && input.requestID) {
|
|
2913
|
+
return { ...notification, actionable: { requestID: input.requestID } };
|
|
2914
|
+
}
|
|
2915
|
+
return notification;
|
|
2702
2916
|
};
|
|
2703
2917
|
var questionHeadline = (questions) => {
|
|
2704
2918
|
const first = questions?.find((entry) => typeof entry?.question === "string" && entry.question.trim());
|
|
@@ -2779,6 +2993,7 @@ var SERVICE3 = "notify";
|
|
|
2779
2993
|
var COOLDOWN_MS2 = 5e3;
|
|
2780
2994
|
var MAX_INFLIGHT_HELPERS = 4;
|
|
2781
2995
|
var HELPER_TIMEOUT_MS = 3e4;
|
|
2996
|
+
var ACTION_TIMEOUT_MS = 10 * 6e4 + 3e4;
|
|
2782
2997
|
var MAX_TRACKED_SESSIONS = 500;
|
|
2783
2998
|
var SOUND_ENV = "LACODE_SOUND";
|
|
2784
2999
|
var SOUND_OFF_VALUES = ["0", "false", "off", "no"];
|
|
@@ -2818,11 +3033,50 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
2818
3033
|
};
|
|
2819
3034
|
const log = (level, message, extra) => client.app.log({ body: { service: SERVICE3, level, message, extra } }).catch(() => {
|
|
2820
3035
|
});
|
|
3036
|
+
const answerPermission = async (sessionID, requestID, action) => {
|
|
3037
|
+
const response = PERMISSION_REPLIES.find((candidate) => candidate === action);
|
|
3038
|
+
if (!response) {
|
|
3039
|
+
void log("warn", `notification action ignored: ${JSON.stringify(action)} is not a permission reply`, {
|
|
3040
|
+
sessionID,
|
|
3041
|
+
requestID
|
|
3042
|
+
});
|
|
3043
|
+
return;
|
|
3044
|
+
}
|
|
3045
|
+
const errorStatus = (error) => {
|
|
3046
|
+
const candidate = error;
|
|
3047
|
+
const status2 = candidate?.response?.status ?? candidate?.cause?.status ?? candidate?.status;
|
|
3048
|
+
return typeof status2 === "number" ? status2 : void 0;
|
|
3049
|
+
};
|
|
3050
|
+
const reply = await client.postSessionIdPermissionsPermissionId({
|
|
3051
|
+
path: { id: sessionID, permissionID: requestID },
|
|
3052
|
+
body: { response }
|
|
3053
|
+
}).then((result) => ({ result })).catch((error) => ({ error }));
|
|
3054
|
+
const status = "result" in reply ? reply.result.response?.status : errorStatus(reply.error);
|
|
3055
|
+
if (status === 404) {
|
|
3056
|
+
void log("debug", "permission already answered", { sessionID, requestID });
|
|
3057
|
+
return;
|
|
3058
|
+
}
|
|
3059
|
+
if ("error" in reply) {
|
|
3060
|
+
void log("warn", `permission reply failed: ${errorDetail(reply.error) ?? String(reply.error)}`, {
|
|
3061
|
+
sessionID,
|
|
3062
|
+
requestID
|
|
3063
|
+
});
|
|
3064
|
+
return;
|
|
3065
|
+
}
|
|
3066
|
+
if (reply.result.error) {
|
|
3067
|
+
void log("warn", `permission reply failed: ${errorDetail(reply.result.error) ?? String(reply.result.error)}`, {
|
|
3068
|
+
sessionID,
|
|
3069
|
+
requestID
|
|
3070
|
+
});
|
|
3071
|
+
return;
|
|
3072
|
+
}
|
|
3073
|
+
void log("info", "permission answered from the notification", { sessionID, requestID, response });
|
|
3074
|
+
};
|
|
2821
3075
|
const fetchInfo = async (sessionID) => {
|
|
2822
3076
|
const info = await client.session.get({ path: { id: sessionID }, query: { directory } }).then((result) => result.data).catch(() => void 0);
|
|
2823
3077
|
return info;
|
|
2824
3078
|
};
|
|
2825
|
-
const deliverViaHelper = (title, message, urgency, sound) => new Promise((resolve3) => {
|
|
3079
|
+
const deliverViaHelper = (title, message, urgency, sound, requestID) => new Promise((resolve3) => {
|
|
2826
3080
|
if (inflight >= maxInflight) {
|
|
2827
3081
|
void log("warn", `notification dropped: ${inflight} helpers already in flight`, { title });
|
|
2828
3082
|
resolve3({ ok: false, detail: `too many notification helpers in flight (${inflight})` });
|
|
@@ -2837,7 +3091,13 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
2837
3091
|
resolve3({ ok: false, detail: "no oc-notify helper found" });
|
|
2838
3092
|
return;
|
|
2839
3093
|
}
|
|
2840
|
-
const
|
|
3094
|
+
const actionable = Boolean(requestID);
|
|
3095
|
+
const timeoutMs = actionable ? ACTION_TIMEOUT_MS : helperTimeoutMs;
|
|
3096
|
+
const child = actionable ? spawn(helper, helperArgv(helper, title, message, urgency, "utilities-terminal", sound), {
|
|
3097
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3098
|
+
detached: false,
|
|
3099
|
+
env: { ...process.env, [REQUEST_ID_ENV]: requestID }
|
|
3100
|
+
}) : spawn(helper, helperArgv(helper, title, message, urgency, "utilities-terminal", sound), {
|
|
2841
3101
|
stdio: ["ignore", "ignore", "pipe"],
|
|
2842
3102
|
detached: true
|
|
2843
3103
|
});
|
|
@@ -2856,11 +3116,15 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
2856
3116
|
child.kill("SIGKILL");
|
|
2857
3117
|
} catch {
|
|
2858
3118
|
}
|
|
2859
|
-
void log("warn", `notification helper timed out after ${
|
|
2860
|
-
finish({ ok: false, detail: `helper timed out after ${
|
|
2861
|
-
},
|
|
3119
|
+
void log("warn", `notification helper timed out after ${timeoutMs}ms`, { title });
|
|
3120
|
+
finish({ ok: false, detail: `helper timed out after ${timeoutMs}ms` });
|
|
3121
|
+
}, timeoutMs);
|
|
2862
3122
|
timer.unref?.();
|
|
3123
|
+
let stdout = "";
|
|
2863
3124
|
let stderr = "";
|
|
3125
|
+
child.stdout?.on("data", (chunk) => {
|
|
3126
|
+
if (stdout.length < 400) stdout += String(chunk);
|
|
3127
|
+
});
|
|
2864
3128
|
child.stderr?.on("data", (chunk) => {
|
|
2865
3129
|
if (stderr.length < 400) stderr += String(chunk);
|
|
2866
3130
|
});
|
|
@@ -2877,12 +3141,13 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
2877
3141
|
message
|
|
2878
3142
|
});
|
|
2879
3143
|
}
|
|
2880
|
-
|
|
3144
|
+
const action = actionable ? stdout.trim() : "";
|
|
3145
|
+
finish({ ok, detail: noise || void 0, ...action ? { action } : {} });
|
|
2881
3146
|
});
|
|
2882
|
-
child.unref();
|
|
3147
|
+
if (!actionable) child.unref();
|
|
2883
3148
|
});
|
|
2884
3149
|
const deliver = deps.deliver ?? deliverViaHelper;
|
|
2885
|
-
const announce = async (sessionID, outcome, detail) => {
|
|
3150
|
+
const announce = async (sessionID, outcome, detail, requestID) => {
|
|
2886
3151
|
try {
|
|
2887
3152
|
const info = await fetchInfo(sessionID);
|
|
2888
3153
|
if (!info) {
|
|
@@ -2898,7 +3163,8 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
2898
3163
|
sawMessage: active.has(sessionID),
|
|
2899
3164
|
// Either kind of block suppresses the idle event's "finished": a session
|
|
2900
3165
|
// parked on a permission prompt has not finished, it is waiting.
|
|
2901
|
-
awaitingResponse: pending.has(sessionID) || permissions.has(sessionID)
|
|
3166
|
+
awaitingResponse: pending.has(sessionID) || permissions.has(sessionID),
|
|
3167
|
+
requestID
|
|
2902
3168
|
});
|
|
2903
3169
|
if (!decision.notify) {
|
|
2904
3170
|
void log("debug", `suppressed: ${decision.reason}`, { sessionID });
|
|
@@ -2916,8 +3182,10 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
2916
3182
|
}
|
|
2917
3183
|
const message = isRemote ? `${decision.message} (remote)` : decision.message;
|
|
2918
3184
|
const sound = soundOn && NOTIFY_OUTCOMES.includes(outcome) ? outcome : "none";
|
|
2919
|
-
const sent = await deliver(decision.title, message, decision.urgency, sound);
|
|
2920
|
-
|
|
3185
|
+
const sent = await deliver(decision.title, message, decision.urgency, sound, requestID);
|
|
3186
|
+
if (sent.action && requestID) await answerPermission(sessionID, requestID, sent.action);
|
|
3187
|
+
const answered = Boolean(sent.action);
|
|
3188
|
+
void log(answered || sent.ok ? "info" : "error", answered ? "permission answered from the notification" : sent.ok ? "notified" : "notification failed", {
|
|
2921
3189
|
sessionID,
|
|
2922
3190
|
outcome,
|
|
2923
3191
|
title: decision.title,
|
|
@@ -2961,7 +3229,8 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
2961
3229
|
if (!sessionID2) return;
|
|
2962
3230
|
permissions.add(sessionID2);
|
|
2963
3231
|
rememberActive(sessionID2);
|
|
2964
|
-
|
|
3232
|
+
const requestID = typeof properties?.id === "string" && properties.id ? properties.id : void 0;
|
|
3233
|
+
void announce(sessionID2, "permission", permissionHeadline(properties) || void 0, requestID);
|
|
2965
3234
|
return;
|
|
2966
3235
|
}
|
|
2967
3236
|
if (event.type === "permission.replied") {
|
|
@@ -3487,7 +3756,7 @@ var omniRouteHooks = async (input, options, deps = {}) => {
|
|
|
3487
3756
|
|
|
3488
3757
|
// src/lib/selfupdate.ts
|
|
3489
3758
|
import { spawn as spawn2 } from "node:child_process";
|
|
3490
|
-
import { closeSync, mkdirSync as mkdirSync3, openSync, readFileSync as
|
|
3759
|
+
import { closeSync, mkdirSync as mkdirSync3, openSync, readFileSync as readFileSync5, renameSync as renameSync2, rmSync, statSync as statSync3, unlinkSync as unlinkSync3, writeFileSync as writeFileSync3, writeSync } from "node:fs";
|
|
3491
3760
|
import { homedir as homedir3 } from "node:os";
|
|
3492
3761
|
import { dirname as dirname3, join as join6, resolve as resolve2 } from "node:path";
|
|
3493
3762
|
var REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
@@ -3514,7 +3783,7 @@ function compareSemver(a, b) {
|
|
|
3514
3783
|
}
|
|
3515
3784
|
function readInstalledVersion(packageRoot, read = (path4) => {
|
|
3516
3785
|
try {
|
|
3517
|
-
return
|
|
3786
|
+
return readFileSync5(path4, "utf8");
|
|
3518
3787
|
} catch {
|
|
3519
3788
|
return void 0;
|
|
3520
3789
|
}
|
|
@@ -3574,7 +3843,7 @@ async function fetchLatest(fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
|
3574
3843
|
}
|
|
3575
3844
|
function readState(path4 = STATE_PATH) {
|
|
3576
3845
|
try {
|
|
3577
|
-
return JSON.parse(
|
|
3846
|
+
return JSON.parse(readFileSync5(path4, "utf8"));
|
|
3578
3847
|
} catch {
|
|
3579
3848
|
return {};
|
|
3580
3849
|
}
|
|
@@ -3613,7 +3882,7 @@ function acquireLock(path4 = LOCK_PATH, now = Date.now()) {
|
|
|
3613
3882
|
} catch {
|
|
3614
3883
|
let orphaned = false;
|
|
3615
3884
|
try {
|
|
3616
|
-
const pid = Number.parseInt(
|
|
3885
|
+
const pid = Number.parseInt(readFileSync5(path4, "utf8").trim(), 10);
|
|
3617
3886
|
orphaned = !Number.isFinite(pid) || pid <= 0 || !isProcessAlive(pid);
|
|
3618
3887
|
} catch {
|
|
3619
3888
|
orphaned = true;
|