@lawaty/lacode 0.0.0-stage → 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 +415 -2
- package/dist/agents.js +819 -0
- package/dist/commands.js +109 -0
- package/dist/index.js +2250 -0
- package/dist/lacode.js +223 -0
- package/dist/pool.js +487 -0
- package/dist/tunnel.js +150 -0
- package/docs/BENCHMARKS.md +127 -0
- package/docs/CONTEXT.md +211 -0
- package/docs/TUNNEL.md +141 -0
- package/docs/USER-STORIES.md +396 -0
- package/package.json +58 -4
- package/presets/README.md +121 -0
- package/presets/free-tier.json +60 -0
package/dist/tunnel.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// src/tunnel/index.ts
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
var ICONS = [
|
|
6
|
+
"utilities-terminal",
|
|
7
|
+
"dialog-information",
|
|
8
|
+
"dialog-error",
|
|
9
|
+
"dialog-warning",
|
|
10
|
+
"system-run",
|
|
11
|
+
"emblem-default"
|
|
12
|
+
];
|
|
13
|
+
function resolveTunnel(env = process.env) {
|
|
14
|
+
const port = Number(env.OC_TUNNEL_PORT);
|
|
15
|
+
return {
|
|
16
|
+
user: env.OC_LOCAL_USER ?? "",
|
|
17
|
+
port: Number.isFinite(port) && port > 0 ? port : 10022,
|
|
18
|
+
key: env.OC_NOTIFY_KEY ?? join(homedir(), ".ssh", "lacode_notify"),
|
|
19
|
+
connectTimeoutMs: Number(env.OC_CONNECT_TIMEOUT_MS) || 5e3
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function isRemote(env = process.env) {
|
|
23
|
+
return Boolean(env.SSH_CLIENT || env.SSH_TTY);
|
|
24
|
+
}
|
|
25
|
+
function normalizeUrgency(raw) {
|
|
26
|
+
return raw === "low" || raw === "critical" || raw === "normal" ? raw : "normal";
|
|
27
|
+
}
|
|
28
|
+
function normalizeIcon(raw) {
|
|
29
|
+
return ICONS.includes(raw) ? raw : "utilities-terminal";
|
|
30
|
+
}
|
|
31
|
+
var safeText = (raw, fallback, max) => {
|
|
32
|
+
const clean = raw.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
|
|
33
|
+
if (!clean) return fallback;
|
|
34
|
+
return clean.length > max ? `${clean.slice(0, max - 1)}\u2026` : clean;
|
|
35
|
+
};
|
|
36
|
+
var safeTitle = (raw) => safeText(raw ?? "", "LaCode", 120);
|
|
37
|
+
var safeMessage = (raw) => safeText(raw ?? "", "", 400);
|
|
38
|
+
var FIELD_SEPARATOR = "\n";
|
|
39
|
+
var encodePayload = (request) => [
|
|
40
|
+
safeTitle(request.title),
|
|
41
|
+
safeMessage(request.message),
|
|
42
|
+
normalizeUrgency(request.urgency),
|
|
43
|
+
normalizeIcon(request.icon)
|
|
44
|
+
].join(FIELD_SEPARATOR);
|
|
45
|
+
var SSH_OPTS = [
|
|
46
|
+
"-o",
|
|
47
|
+
"BatchMode=yes",
|
|
48
|
+
"-o",
|
|
49
|
+
"IdentitiesOnly=yes",
|
|
50
|
+
"-o",
|
|
51
|
+
"ClearAllForwardings=yes",
|
|
52
|
+
"-o",
|
|
53
|
+
"LogLevel=ERROR",
|
|
54
|
+
"-o",
|
|
55
|
+
"StrictHostKeyChecking=accept-new"
|
|
56
|
+
];
|
|
57
|
+
var run = (command, args, timeout) => {
|
|
58
|
+
try {
|
|
59
|
+
const result = spawnSync(command, args, { timeout, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
60
|
+
return {
|
|
61
|
+
ok: result.status === 0,
|
|
62
|
+
stdout: result.stdout ?? "",
|
|
63
|
+
stderr: (result.stderr ?? "").trim(),
|
|
64
|
+
// spawnSync reports a timeout as a null status and an ETIMEDOUT error; both
|
|
65
|
+
// are "did not arrive", not "crashed", and neither may throw.
|
|
66
|
+
detail: result.error ? String(result.error.code ?? result.error.message) : void 0
|
|
67
|
+
};
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return { ok: false, stdout: "", stderr: "", detail: String(error) };
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
var have = (command) => {
|
|
73
|
+
try {
|
|
74
|
+
return spawnSync("sh", ["-c", `command -v ${command}`], { encoding: "utf8" }).status === 0;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
function sendLocal(request) {
|
|
80
|
+
if (!have("notify-send")) return { ok: false, detail: "notify-send is not installed", transport: "local" };
|
|
81
|
+
const args = [
|
|
82
|
+
safeTitle(request.title),
|
|
83
|
+
safeMessage(request.message),
|
|
84
|
+
`--urgency=${normalizeUrgency(request.urgency)}`,
|
|
85
|
+
`--icon=${normalizeIcon(request.icon)}`
|
|
86
|
+
];
|
|
87
|
+
const result = run("notify-send", args, 5e3);
|
|
88
|
+
return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "local" };
|
|
89
|
+
}
|
|
90
|
+
function sendRemote(request, tunnel) {
|
|
91
|
+
if (!tunnel.user) return { ok: false, detail: "OC_LOCAL_USER is unset; the desktop user for the tunnel is unknown", transport: "remote" };
|
|
92
|
+
const args = [
|
|
93
|
+
...SSH_OPTS,
|
|
94
|
+
"-o",
|
|
95
|
+
`ConnectTimeout=${Math.max(1, Math.round(tunnel.connectTimeoutMs / 1e3))}`,
|
|
96
|
+
"-i",
|
|
97
|
+
tunnel.key,
|
|
98
|
+
"-p",
|
|
99
|
+
String(tunnel.port),
|
|
100
|
+
`${tunnel.user}@localhost`,
|
|
101
|
+
"--",
|
|
102
|
+
encodePayload(request)
|
|
103
|
+
];
|
|
104
|
+
const result = run("ssh", args, tunnel.connectTimeoutMs + 2e3);
|
|
105
|
+
return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "remote" };
|
|
106
|
+
}
|
|
107
|
+
function notify(request, env = process.env) {
|
|
108
|
+
try {
|
|
109
|
+
return isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return { ok: false, detail: String(error), transport: "none" };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function forward(host, argv, options = {}) {
|
|
115
|
+
const env = options.env ?? process.env;
|
|
116
|
+
const timeoutMs = options.timeoutMs ?? (Number(env.OC_SSH_TIMEOUT_MS) || 3e4);
|
|
117
|
+
const result = run("ssh", [...SSH_OPTS, "-o", `ConnectTimeout=${Math.max(1, Math.round(timeoutMs / 1e3))}`, host, "--", ...argv], timeoutMs);
|
|
118
|
+
return {
|
|
119
|
+
ok: result.ok,
|
|
120
|
+
detail: result.ok ? void 0 : result.stderr || result.detail,
|
|
121
|
+
transport: "remote",
|
|
122
|
+
stdout: result.stdout
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function describe(env = process.env) {
|
|
126
|
+
const tunnel = resolveTunnel(env);
|
|
127
|
+
return {
|
|
128
|
+
running: isRemote(env) ? "remote (over ssh)" : "local",
|
|
129
|
+
user: tunnel.user || "(unset)",
|
|
130
|
+
port: tunnel.port,
|
|
131
|
+
key: tunnel.key,
|
|
132
|
+
notifySend: have("notify-send")
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
export {
|
|
136
|
+
FIELD_SEPARATOR,
|
|
137
|
+
ICONS,
|
|
138
|
+
describe,
|
|
139
|
+
encodePayload,
|
|
140
|
+
forward,
|
|
141
|
+
isRemote,
|
|
142
|
+
normalizeIcon,
|
|
143
|
+
normalizeUrgency,
|
|
144
|
+
notify,
|
|
145
|
+
resolveTunnel,
|
|
146
|
+
safeMessage,
|
|
147
|
+
safeTitle,
|
|
148
|
+
sendLocal,
|
|
149
|
+
sendRemote
|
|
150
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Cost benchmarks
|
|
2
|
+
|
|
3
|
+
**This file contains no results.** It explains how to produce your own. The table
|
|
4
|
+
below is empty on purpose, and the harness refuses to invent a number to fill it
|
|
5
|
+
in.
|
|
6
|
+
|
|
7
|
+
There is no published savings figure for LaCode, because none has been
|
|
8
|
+
independently measured. A number in a README is a claim about somebody's
|
|
9
|
+
workload, and yours is not that workload. The plugin changes two things: *which
|
|
10
|
+
model does the bulk exploration work*, and *how much of that work has to be done
|
|
11
|
+
at all* — agents start from the maintained context map in [CONTEXT.md](CONTEXT.md)
|
|
12
|
+
instead of re-deriving the structure of your repository on every task. What that
|
|
13
|
+
is worth in money depends entirely on your token mix and on what you compare the
|
|
14
|
+
pooled run against. So the harness exists to let you compute the number for
|
|
15
|
+
yourself rather than to hand you one to believe.
|
|
16
|
+
|
|
17
|
+
## What the harness measures
|
|
18
|
+
|
|
19
|
+
`tests/benchmark/run.mjs` runs one scenario twice — once on your main model, once
|
|
20
|
+
on a pooled model — and prints tokens and cost for both.
|
|
21
|
+
|
|
22
|
+
The comparison only means something because both runs do *identical* work. The
|
|
23
|
+
scenario is stated once in the harness and passed to both configurations: the
|
|
24
|
+
same four prompts, the same number of calls, a different model doing them.
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
Map the modules that handle authentication and the entry points that reach them.
|
|
28
|
+
Find every place a request is authorized and list the file:line for each check.
|
|
29
|
+
List the config files that affect routing and the defaults each one sets.
|
|
30
|
+
Find the test files covering the auth path and name what each asserts.
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
It is a small, deliberately mechanical scenario. It is not a representative
|
|
34
|
+
workload for you, and the harness does not claim it is. Its only job is to make
|
|
35
|
+
the two sides of the ratio comparable.
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
|
|
39
|
+
| Command | What it does |
|
|
40
|
+
| --- | --- |
|
|
41
|
+
| `npm run benchmark` | Simulation (the offline default). No credentials, no network. |
|
|
42
|
+
| `npm run benchmark -- --real` | Runs both configurations for real against the `opencode` binary and reads token counts back out of the runs. |
|
|
43
|
+
| `npm run benchmark -- --refresh` | Rebuilds `tests/models-snapshot.json` from models.dev first (network), then proceeds as usual. |
|
|
44
|
+
| `npm run benchmark -- --out docs/BENCHMARKS.generated.md` | Writes the generated table to the *generated* filename. See below. |
|
|
45
|
+
|
|
46
|
+
Useful flags for both modes:
|
|
47
|
+
|
|
48
|
+
| Flag | Default | Meaning |
|
|
49
|
+
| --- | --- | --- |
|
|
50
|
+
| `--baseline-model` / `--model` | `anthropic/claude-opus-4-1` | Which model plays "what it would have cost without LaCode". A **name**, never a price. |
|
|
51
|
+
| `--calls` | `40` | Simulation only. Number of delegated calls in the task profile. |
|
|
52
|
+
| `--input-tokens` | `12000` | Simulation only. Input tokens per call. |
|
|
53
|
+
| `--output-tokens` | `1500` | Simulation only. Output tokens per call. |
|
|
54
|
+
| `--allow-unknown` | off | Accepts `unknown` in the cost column for a model you named explicitly. Without it the run exits non-zero. |
|
|
55
|
+
|
|
56
|
+
`--real` additionally honours `OPENCODE_BIN` to locate the opencode binary, and
|
|
57
|
+
reads credentials from `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` /
|
|
58
|
+
`OPENROUTER_API_KEY` / `OPENCODE_API_KEY` or from the opencode `auth.json`.
|
|
59
|
+
|
|
60
|
+
## Simulation vs real
|
|
61
|
+
|
|
62
|
+
They are different kinds of claim, and the output says which one you are looking
|
|
63
|
+
at in every table header.
|
|
64
|
+
|
|
65
|
+
**Simulation** (the default) is arithmetic over stated assumptions. It takes the
|
|
66
|
+
task profile above, multiplies it out, and prices it from the pinned snapshot.
|
|
67
|
+
Nothing is executed and nothing is observed. It is a projection, and it is
|
|
68
|
+
useful for exactly one thing: seeing how the two rows compare when you change
|
|
69
|
+
the profile or the models.
|
|
70
|
+
|
|
71
|
+
**Real** runs the scenario twice and reads the token counts back out of each
|
|
72
|
+
run's own output. That is a measurement of the harness scenario on your machine
|
|
73
|
+
on that day. It is still not a measurement of your work.
|
|
74
|
+
|
|
75
|
+
`--real` degrades rather than failing when it cannot run: with no credentials or
|
|
76
|
+
no binary it prints `SKIPPED`, exits 0, and reports nothing. A real run that
|
|
77
|
+
fails mid-way exits 3 and refuses to fall through to a projection. If the runs
|
|
78
|
+
report no token usage at all, that is an error, not a cost of zero.
|
|
79
|
+
|
|
80
|
+
## Pricing
|
|
81
|
+
|
|
82
|
+
Prices come from `tests/models-snapshot.json`, checked in so a run is
|
|
83
|
+
reproducible and offline. Regenerate it with `npm run benchmark -- --refresh`
|
|
84
|
+
(or `node tests/refresh-models-snapshot.mjs`) when you want current figures.
|
|
85
|
+
|
|
86
|
+
A model the snapshot has never seen is **skipped with a named reason**. It is
|
|
87
|
+
never priced at 0 and never priced at a guess. If you name such a model
|
|
88
|
+
explicitly with `--model` / `--baseline-model`, the run exits non-zero and tells
|
|
89
|
+
you to refresh or to pass `--allow-unknown`.
|
|
90
|
+
|
|
91
|
+
This is the reason the results table below is empty. The pinned snapshot covers
|
|
92
|
+
the bundled free preset only; the default baseline model is not in it, so the
|
|
93
|
+
default offline run cannot produce an honest ratio. That is the harness working
|
|
94
|
+
as designed.
|
|
95
|
+
|
|
96
|
+
## Results
|
|
97
|
+
|
|
98
|
+
Run it yourself and put the output here.
|
|
99
|
+
|
|
100
|
+
| configuration | models | calls | input tokens | output tokens | cost |
|
|
101
|
+
| --- | --- | ---: | ---: | ---: | ---: |
|
|
102
|
+
| baseline (main model) | — | not yet measured | — | — | — |
|
|
103
|
+
| LaCode (pooled subagent) | — | not yet measured | — | — | — |
|
|
104
|
+
|
|
105
|
+
Difference: not yet measured — run it yourself.
|
|
106
|
+
|
|
107
|
+
Nothing in this repository asserts a savings percentage. There is no constant
|
|
108
|
+
anywhere in the harness: every ratio in the output is computed at runtime from
|
|
109
|
+
the rows printed above it, with both the numerator and the denominator shown, so
|
|
110
|
+
the arithmetic can be checked by hand. If a number cannot be derived, the
|
|
111
|
+
harness prints `unknown`.
|
|
112
|
+
|
|
113
|
+
## Why the generated table is a separate file
|
|
114
|
+
|
|
115
|
+
`--out` writes to `docs/BENCHMARKS.generated.md`. It **refuses**
|
|
116
|
+
`--out docs/BENCHMARKS.md` and exits 2.
|
|
117
|
+
|
|
118
|
+
That refusal is the point. This file ships inside the npm tarball (`files`
|
|
119
|
+
includes `docs`). A generated table dropped on top of it would replace the
|
|
120
|
+
explanation with a dated artifact that reads as a published measurement of the
|
|
121
|
+
package, and it would outlive the machine and the day that produced it. So the
|
|
122
|
+
hand-written explainer is protected, and the generated output — which carries a
|
|
123
|
+
footer pointing back here — goes to its own gitignored filename.
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
npm run benchmark -- --out docs/BENCHMARKS.generated.md
|
|
127
|
+
```
|
package/docs/CONTEXT.md
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# The context map
|
|
2
|
+
|
|
3
|
+
Every task an agent starts, it starts by exploring: list the directories, open
|
|
4
|
+
the entry points, work out which layer a change belongs to, discover which files
|
|
5
|
+
call which. On a large repository that is the single most expensive part of the
|
|
6
|
+
job, and it happens *every time* — for a question that touches one file, for a
|
|
7
|
+
refactor that moves one module, for a session you start after an hour of
|
|
8
|
+
something else.
|
|
9
|
+
|
|
10
|
+
LaCode keeps a short, maintained map of your repository at `.opencode/context/`
|
|
11
|
+
and makes every agent read it before exploring. The map answers one question —
|
|
12
|
+
**"what should I look at first?"** — and nothing else.
|
|
13
|
+
|
|
14
|
+
This document is the user-facing description of the system: what the files are,
|
|
15
|
+
who writes them, when they are refreshed, and what you can do by hand.
|
|
16
|
+
|
|
17
|
+
## Why it exists
|
|
18
|
+
|
|
19
|
+
Agents are stateless between tasks. Anything an agent learns about your
|
|
20
|
+
codebase in one session is discarded at the end of it, and re-derived — at full
|
|
21
|
+
price — by the next agent. Writing the findings down once, in a form small
|
|
22
|
+
enough to read in one call, is what turns that repeated cost into a single
|
|
23
|
+
upfront one.
|
|
24
|
+
|
|
25
|
+
The mechanism is deliberately narrow, because a map that costs more to read than
|
|
26
|
+
the exploration it replaces is worse than no map:
|
|
27
|
+
|
|
28
|
+
- **Conclusions, not inventories.** "`OrderService` is the application boundary
|
|
29
|
+
for order mutations", not "there is a file called OrderService in
|
|
30
|
+
`src/orders/`". The cartographer is forbidden by name from writing directory
|
|
31
|
+
listings, copying source, or restating what the README already says.
|
|
32
|
+
- **One file per question.** Five files, each with a defined scope, so an agent
|
|
33
|
+
reads one of them rather than all of them.
|
|
34
|
+
- **Canonical entry points.** The highest-value thing in the map is a short
|
|
35
|
+
"Start here" list per subsystem: the five files an agent should open, each
|
|
36
|
+
with a one-phrase reason.
|
|
37
|
+
- **It links out.** Your project's own README, ADRs and docs are authoritative
|
|
38
|
+
and are not duplicated here.
|
|
39
|
+
|
|
40
|
+
On cost: the saving is in **tokens not spent re-discovering structure**, and it
|
|
41
|
+
is paid by exploration tokens rather than by the expensive main model — the
|
|
42
|
+
cartographer that maintains the map runs on a pooled model like every other
|
|
43
|
+
subagent. **No savings percentage is published here, because none has been
|
|
44
|
+
independently measured.** A number derived from somebody else's repository and
|
|
45
|
+
token mix is not a claim about yours; see [BENCHMARKS.md](BENCHMARKS.md) for the
|
|
46
|
+
harness that produces a number for your own workload.
|
|
47
|
+
|
|
48
|
+
## The five files
|
|
49
|
+
|
|
50
|
+
`.opencode/context/` holds exactly these, and nothing else:
|
|
51
|
+
|
|
52
|
+
| File | What belongs in it |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| `architecture.md` | The high-level shape: layers, boundaries, entry points, dependency rules. Read this for an architectural question. |
|
|
55
|
+
| `contexts.md` | The major subsystems. For each: what it is for, where to start reading it, what it depends on, what must not change while you are in there. Read this for a subsystem question. |
|
|
56
|
+
| `conventions.md` | Repository-specific rules: naming, module layout, error handling, testing, persistence. Read this before writing code, not after. |
|
|
57
|
+
| `workflows.md` | The few runtime flows worth tracing end to end — how a request becomes a mutation, how a plugin hook reaches the thing it hooks. Read this when you need to know what actually happens at runtime. |
|
|
58
|
+
| `decisions.md` | Architectural decisions that the repository's own decision log does not already record. Concise, variable length. |
|
|
59
|
+
|
|
60
|
+
Files the user or the plan explicitly names always win over the map. The map is
|
|
61
|
+
a shortcut to the right starting point, not an authority: when it disagrees with
|
|
62
|
+
the source, the source is right and the map is wrong.
|
|
63
|
+
|
|
64
|
+
The map is **local and untracked**. It describes *your* checkout on *this*
|
|
65
|
+
machine, so committing it would churn on every session that edits a file, and
|
|
66
|
+
syncing it to another host would push a description of one machine's projects
|
|
67
|
+
onto hosts that have different ones. If you see `.opencode/context/` in a diff,
|
|
68
|
+
something has gone wrong.
|
|
69
|
+
|
|
70
|
+
## How it stays current
|
|
71
|
+
|
|
72
|
+
The `context-manager` agent maintains the map. It runs in two ways: on its own,
|
|
73
|
+
after a session that changed something, and on demand through the three
|
|
74
|
+
commands — both of which go through the same agent, so they behave the same way.
|
|
75
|
+
|
|
76
|
+
### Automatically
|
|
77
|
+
|
|
78
|
+
After a **root** session goes idle, and only if that session edited a file
|
|
79
|
+
inside the project, LaCode starts a `context-manager` run:
|
|
80
|
+
|
|
81
|
+
1. It collects the paths the session touched, dropping anything generated,
|
|
82
|
+
vendored or outside the project (`node_modules`, `dist`, `coverage`,
|
|
83
|
+
`.git`, and — importantly — the map itself, so the cartographer's own writes
|
|
84
|
+
never trigger another run).
|
|
85
|
+
2. It picks `bootstrap` or `update` from what is on disk: if
|
|
86
|
+
`.opencode/context/` does not exist, the run builds the map from scratch;
|
|
87
|
+
if it exists, the run updates it.
|
|
88
|
+
3. It borrows a slot from the same pool every other subagent uses, creates a
|
|
89
|
+
session, and hands it the changed paths as *starting context* — not as an
|
|
90
|
+
exhaustive list and not as an instruction to document all of them.
|
|
91
|
+
|
|
92
|
+
Three things deliberately do **not** trigger a run: a session that edited
|
|
93
|
+
nothing, a subagent going idle (they go idle constantly, mid-task), and a second
|
|
94
|
+
run within ten minutes of the last one.
|
|
95
|
+
|
|
96
|
+
An update defaults to *no edit*. A changed file is not a changed fact:
|
|
97
|
+
formatting, local renames, added tests and dependency bumps rarely alter what
|
|
98
|
+
the map should say. The cartographer is told to edit only when the work
|
|
99
|
+
introduced or revealed something that will help a future agent — a new boundary,
|
|
100
|
+
a new or moved entry point, a changed workflow, a new convention, an
|
|
101
|
+
architectural decision, a previously undocumented invariant — and to report
|
|
102
|
+
"no update needed" otherwise.
|
|
103
|
+
|
|
104
|
+
If the map is missing entirely, nothing breaks: agents check, find nothing, and
|
|
105
|
+
explore as they always did. The standing rule that tells them to check is
|
|
106
|
+
injected into your config only when you have no `instructions` array of your own.
|
|
107
|
+
|
|
108
|
+
### On demand
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
/context-init # build the map, or verify and refresh an existing one
|
|
112
|
+
/context-update # incremental refresh; accepts a focus, e.g. /context-update src/payments
|
|
113
|
+
/context-review # audit it for staleness, contradictions, duplication and bloat
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
All three delegate to the same agent, which is why they are consistent with the
|
|
117
|
+
automatic run rather than a separate mechanism. `/context-review` treats
|
|
118
|
+
everything outside `.opencode/context/` as read-only: it fixes the map, never
|
|
119
|
+
your source.
|
|
120
|
+
|
|
121
|
+
If you have defined a command of the same name, yours wins and is left exactly
|
|
122
|
+
as you wrote it — the same merge-without-clobber rule the agents follow.
|
|
123
|
+
|
|
124
|
+
## One writer
|
|
125
|
+
|
|
126
|
+
Only `context-manager` writes the map. Every other agent is told never to edit
|
|
127
|
+
it, and to say in its final report if its work revealed something that belongs
|
|
128
|
+
in there. The cartographer, in turn, is denied permission to write anything
|
|
129
|
+
outside the map — not by convention, but by an `edit` rule that allows
|
|
130
|
+
`.opencode/context/**` and nothing else.
|
|
131
|
+
|
|
132
|
+
The rule runs in both directions. The agents that *could* write anywhere else in
|
|
133
|
+
the repository — the root `build` agent and both implement tiers — carry the
|
|
134
|
+
mirror image of the cartographer's grant: `"*": "allow"` followed by a `deny` on
|
|
135
|
+
`.opencode/context/**`, so they edit everything except the map. The instruction
|
|
136
|
+
telling them not to touch it is still there, and still matters — a permission
|
|
137
|
+
denial tells an agent nothing about what it should have done instead — but it is
|
|
138
|
+
no longer the only thing standing between them and the map.
|
|
139
|
+
|
|
140
|
+
This exists because two writers produce a map that is neither current nor
|
|
141
|
+
trustworthy. Concurrent sessions rewriting the same five files produce
|
|
142
|
+
contradictions that no later reader can resolve, and the reader has no way to
|
|
143
|
+
tell which version is newer.
|
|
144
|
+
|
|
145
|
+
Ordering in those rules is not cosmetic. opencode resolves a file permission by
|
|
146
|
+
taking the **last** matching rule in declaration order, with no specificity
|
|
147
|
+
sorting: a `deny` written above a broad `"*": "allow"` loses. LaCode therefore
|
|
148
|
+
writes every narrow rule last, in both files (`src/agents.ts` and your own
|
|
149
|
+
`opencode.jsonc`), and `src/lib/writer-rule.ts` checks that order rather than
|
|
150
|
+
grepping for the word `allow` — which is what stops a correctly-placed deny from
|
|
151
|
+
being reported as a hole.
|
|
152
|
+
|
|
153
|
+
The rule has a sharp edge worth knowing about, because it has already bitten
|
|
154
|
+
this project: **opencode evaluates a file permission against the path relative
|
|
155
|
+
to the project root, not the absolute path the tool was given.** A rule written
|
|
156
|
+
as `*/.opencode/context/**` matches an absolute path and therefore matches
|
|
157
|
+
nothing at all. Both spellings are therefore required — `.opencode/context/**`
|
|
158
|
+
for a normally-rooted project and `*/.opencode/context/**` for one rooted at
|
|
159
|
+
`/` — and LaCode ships both. At startup the plugin checks your config for that,
|
|
160
|
+
and logs an error naming the rules it saw if neither form would ever fire.
|
|
161
|
+
|
|
162
|
+
### The residual risk: a subdirectory worktree
|
|
163
|
+
|
|
164
|
+
Those patterns are matched against `relative(worktree, filepath)`, and the
|
|
165
|
+
spelling that is supposed to fire depends on where the worktree root sits:
|
|
166
|
+
|
|
167
|
+
- **worktree = repo root** → the map is `.opencode/context/architecture.md`, and
|
|
168
|
+
`.opencode/context/**` fires. `*/.opencode/context/**` does not.
|
|
169
|
+
- **worktree = `/`** → the map is `home/<you>/…/.opencode/context/…`, and only
|
|
170
|
+
`*/.opencode/context/**` fires.
|
|
171
|
+
|
|
172
|
+
Shipping both spellings covers those two. What it does **not** cover is a third
|
|
173
|
+
case: if the worktree is a subdirectory rather than the repo root — you opened
|
|
174
|
+
opencode one directory down from the repository root — then the evaluated path
|
|
175
|
+
gains a prefix that matches *neither* pattern. Both rules match nothing, the
|
|
176
|
+
deny against other agents silently stops applying, and **nothing is reported**,
|
|
177
|
+
because a rule matching nothing is not an error in opencode's evaluator and the
|
|
178
|
+
startup check has no way to see a worktree it was not told about.
|
|
179
|
+
|
|
180
|
+
How to detect it: open opencode from the repository root, not from a
|
|
181
|
+
subdirectory of it. If `ls .opencode/context` from your shell's current
|
|
182
|
+
directory does not list the five files, your shell is not at the root and
|
|
183
|
+
neither is opencode's worktree. The cost of getting it wrong is not a crash —
|
|
184
|
+
it is a silent return to a map maintained only by prompt, which is the exact
|
|
185
|
+
state US-21 exists to eliminate.
|
|
186
|
+
|
|
187
|
+
The symptom of getting this wrong is not a crash. The cartographer's writes are
|
|
188
|
+
simply denied, its run still "succeeds", and the map quietly stops changing. So
|
|
189
|
+
a run that changed nothing on disk is reported as a warning, not as a success:
|
|
190
|
+
if you see `auto update finished without changing the map` repeatedly, the first
|
|
191
|
+
thing to check is that permission rule, not the map's content.
|
|
192
|
+
|
|
193
|
+
## What you will see in the log
|
|
194
|
+
|
|
195
|
+
| Line | Meaning |
|
|
196
|
+
| --- | --- |
|
|
197
|
+
| `borrowed pool slot N (model, wW) for auto update` | A refresh started, on a pooled model. |
|
|
198
|
+
| `auto update finished` | The run completed and changed at least one map file. |
|
|
199
|
+
| `auto update finished without changing the map` | Nothing landed. Either nothing needed saying, or the cartographer could not write. |
|
|
200
|
+
| `auto update failed: …` | The run errored. The paths it was given are queued again for the next window, so the work is not lost. |
|
|
201
|
+
| `context map is not writable: …` | Your config's one-writer rule does not match the path opencode evaluates. Nothing will update until you fix it. |
|
|
202
|
+
| `context map has more than one writer: …` | An agent other than `context-manager` would be granted the map. The offending agent and the winning rule are named. |
|
|
203
|
+
|
|
204
|
+
## Reference
|
|
205
|
+
|
|
206
|
+
- The agent and command definitions, as injected: `src/agents.ts`, `src/commands.ts`.
|
|
207
|
+
- The idle/edit trigger: `src/plugins/context-autoupdate.ts`.
|
|
208
|
+
- The one-writer rule check: `src/lib/writer-rule.ts`.
|
|
209
|
+
- The behavioural contracts, with acceptance criteria a test asserts:
|
|
210
|
+
[USER-STORIES.md](USER-STORIES.md), US-19 to US-22.
|
|
211
|
+
- Tests: `tests/stories/g-context-management.test.mjs`.
|
package/docs/TUNNEL.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Notifications from remote hosts
|
|
2
|
+
|
|
3
|
+
opencode runs on your laptop. It also runs on the remote hosts you ssh into. A
|
|
4
|
+
notification raised on a remote host has nowhere to go: that machine has no
|
|
5
|
+
desktop, no session bus, and possibly no `notify-send`. This is the hop that
|
|
6
|
+
gets it back.
|
|
7
|
+
|
|
8
|
+
## The shape
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
remote host your desktop
|
|
12
|
+
┌──────────────────┐ ┌────────────────────────┐
|
|
13
|
+
│ opencode session │ │ oc-notify-receiver │
|
|
14
|
+
│ │ │ ssh (reverse forward) │ (forced command, │
|
|
15
|
+
│ ▼ │ ───────────────────────► │ restrict) │
|
|
16
|
+
│ bin/lacode │ ONE argument: │ │ │
|
|
17
|
+
│ notify │ title\nmessage\n │ ▼ │
|
|
18
|
+
│ │ urgency\nicon │ notify-send │
|
|
19
|
+
└──────────────────┘ └────────────────────────┘
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Two things are load-bearing here.
|
|
23
|
+
|
|
24
|
+
**The reverse forward.** The remote opens an SSH connection to *your* machine
|
|
25
|
+
and asks sshd on the desktop to listen on a port. A notification is then an ssh
|
|
26
|
+
call to `localhost:<port>` with a dedicated key that is authorized to run
|
|
27
|
+
exactly one command. It is not a login, it is not a shell, and it does not
|
|
28
|
+
depend on which terminal connected first.
|
|
29
|
+
|
|
30
|
+
**One argument, not four.** ssh concatenates everything after the host into a
|
|
31
|
+
single command string, and a forced command starts with *no* positional
|
|
32
|
+
parameters. A sender that passes four quoted arguments arrives as the single
|
|
33
|
+
string `"a b c d"`; a receiver reading `$1..$4` sees four empty strings, falls
|
|
34
|
+
back to four defaults, and pops up a blank notification. So the payload crosses
|
|
35
|
+
as one newline-delimited argument:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
title\nmessage\nurgency\nicon
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Newline is safe as the delimiter because both ends strip control characters from
|
|
42
|
+
the free-text fields before assembling or reading it, and `urgency` and `icon`
|
|
43
|
+
come from whitelists. No field can contain the delimiter, so a message full of
|
|
44
|
+
spaces, quotes or `*` survives intact.
|
|
45
|
+
|
|
46
|
+
## Sending
|
|
47
|
+
|
|
48
|
+
The CLI picks the transport by where it is running — `SSH_CLIENT` or `SSH_TTY`
|
|
49
|
+
means remote, so it forwards; otherwise it calls `notify-send` directly.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
lacode notify "build finished" "lapode: 4 files changed" normal dialog-information
|
|
53
|
+
lacode notify "question" "which database should I use?" critical dialog-warning
|
|
54
|
+
lacode forward web-01 -- systemctl --user status oc-tunnel
|
|
55
|
+
lacode doctor
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`notify` is fire-and-forget by design. An unreachable desktop, a missing key, a
|
|
59
|
+
missing `notify-send` — all of them print a warning on stderr and exit 0. A
|
|
60
|
+
notification must never be able to fail the process that tried to send one; an
|
|
61
|
+
agent must not go down because a laptop went to sleep. `forward` is the
|
|
62
|
+
exception: it exits with the remote's status, because its caller wants to know.
|
|
63
|
+
|
|
64
|
+
`lacode doctor` prints what the environment resolves to and never gates on it.
|
|
65
|
+
|
|
66
|
+
## Configuration
|
|
67
|
+
|
|
68
|
+
| Variable | Default | Meaning |
|
|
69
|
+
| --- | --- | --- |
|
|
70
|
+
| `OC_LOCAL_USER` | *(unset)* | The desktop user the tunnel authenticates as. **Required for the remote hop.** |
|
|
71
|
+
| `OC_TUNNEL_PORT` | `10022` | Reverse-forward listener port on the desktop. Give each remote host its own. |
|
|
72
|
+
| `OC_NOTIFY_KEY` | `~/.ssh/lacode_notify` | The dedicated key. Never a personal key. |
|
|
73
|
+
| `OC_CONNECT_TIMEOUT_MS` | `5000` | Ceiling on the hop. |
|
|
74
|
+
| `OC_SSH_TIMEOUT_MS` | `30000` | Ceiling on `forward`. |
|
|
75
|
+
|
|
76
|
+
`OC_LOCAL_USER` is read rather than probed on purpose. `id -un` on a remote
|
|
77
|
+
account frequently returns `root`, and a dbus path built from that reaches
|
|
78
|
+
nobody's desktop. Guessing the desktop user is worse than failing.
|
|
79
|
+
|
|
80
|
+
## Setting the tunnel up on the desktop
|
|
81
|
+
|
|
82
|
+
The receiver runs on the machine with the desktop, as an `authorized_keys`
|
|
83
|
+
forced command restricted to that one script:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
restrict,command="/home/you/.local/bin/lacode-notify-receiver" ssh-ed25519 AAAA… oc-notify tunnel auth
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`restrict` is doing the security work: no shell, no PTY, no agent forwarding, no
|
|
90
|
+
port forwarding. The key can do exactly one thing and nothing else.
|
|
91
|
+
|
|
92
|
+
The receiver must satisfy this contract:
|
|
93
|
+
|
|
94
|
+
1. read the payload from **`$SSH_ORIGINAL_COMMAND`**, never from `$1`..`$4`;
|
|
95
|
+
2. **never** `eval` it, never hand it to `sh -c`, never splice it into an
|
|
96
|
+
argument list — it is attacker-controlled, because it is whatever the remote
|
|
97
|
+
user typed;
|
|
98
|
+
3. split it on newlines into title / message / urgency / icon, each with a
|
|
99
|
+
default;
|
|
100
|
+
4. whitelist `urgency` against `low|normal|critical` and `icon` against the six
|
|
101
|
+
supported names, both of which become command-line flags;
|
|
102
|
+
5. resolve `DISPLAY` / `DBUS_SESSION_BUS_ADDRESS` for the session it is running
|
|
103
|
+
in, then `exec notify-send "$TITLE" "$MSG" --urgency=… --icon=…`.
|
|
104
|
+
|
|
105
|
+
> **Note.** The published package ships the built CLI (`dist/lacode.js`, linked
|
|
106
|
+
> as `lacode`) and the tunnel behind it (`dist/tunnel.js`, importable as
|
|
107
|
+
> `@lawaty/lacode/tunnel`), not a receiver script. The receiver is the
|
|
108
|
+
> desktop-side half and is yours to install — keep it out of your shell's
|
|
109
|
+
> `$PATH` reach and pinned in `authorized_keys`.
|
|
110
|
+
|
|
111
|
+
If the two halves disagree you get a blank popup, which points nowhere. A
|
|
112
|
+
receiver that sees a payload with no newline in it should say so on stderr
|
|
113
|
+
rather than silently defaulting everything.
|
|
114
|
+
|
|
115
|
+
## Holding the forward open
|
|
116
|
+
|
|
117
|
+
The tunnel has to exist whenever a notification is sent. Anything that survives
|
|
118
|
+
your logout works: a `--user` systemd unit is the usual choice, one per remote
|
|
119
|
+
host, each with its own port.
|
|
120
|
+
|
|
121
|
+
```ini
|
|
122
|
+
# ~/.config/systemd/user/oc-tunnel@<host>.service
|
|
123
|
+
[Service]
|
|
124
|
+
ExecStart=/usr/bin/ssh -NT -R 10022:localhost -o ClearAllForwardings=yes -o ExitOnForwardFailure=yes \
|
|
125
|
+
-i %h/.ssh/lacode_notify you@localhost
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Then `systemctl --user enable --now oc-tunnel@web-01`.
|
|
129
|
+
|
|
130
|
+
Two details that matter:
|
|
131
|
+
|
|
132
|
+
- **`-o ExitOnForwardFailure=yes`** — without it, ssh stays up with no forward
|
|
133
|
+
and the notifications fail silently.
|
|
134
|
+
- **`-o ClearAllForwardings=yes`** on every *other* ssh call that touches that
|
|
135
|
+
host, including rsync's `-e`. A `RemoteForward` in `~/.ssh/config` competes
|
|
136
|
+
for the same port, and a half-claimed listener starves the one session that
|
|
137
|
+
needed it.
|
|
138
|
+
|
|
139
|
+
Do not hold the forward open with a `RemoteForward` line in `~/.ssh/config`. It
|
|
140
|
+
is per-connection: the port exists only while some terminal is open, and it is
|
|
141
|
+
claimed by whichever terminal connects first.
|