@chipschat/opencode 1.0.4 → 1.0.7

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/dist/main.js CHANGED
@@ -8,9 +8,9 @@ var __export = (target, all) => {
8
8
  };
9
9
 
10
10
  // src/main.ts
11
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "node:fs";
12
- import { homedir as homedir6 } from "node:os";
13
- import { dirname as dirname4, join as join9, resolve as resolve4 } from "node:path";
11
+ import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "node:fs";
12
+ import { homedir as homedir7 } from "node:os";
13
+ import { dirname as dirname5, join as join10, resolve as resolve4 } from "node:path";
14
14
 
15
15
  // node_modules/zod/v4/classic/external.js
16
16
  var external_exports = {};
@@ -4871,10 +4871,10 @@ function partPattern(schema) {
4871
4871
  const own2 = schema._zod.pattern?.source;
4872
4872
  const inner = def.innerType ?? schema._zod.innerType;
4873
4873
  if (inner) {
4874
- const before = inner._zod.pattern?.source;
4874
+ const before2 = inner._zod.pattern?.source;
4875
4875
  const after = partPattern(inner);
4876
- if (own2 && before && after && after !== before) {
4877
- return own2.replace(cleanRegex(before), () => cleanRegex(after));
4876
+ if (own2 && before2 && after && after !== before2) {
4877
+ return own2.replace(cleanRegex(before2), () => cleanRegex(after));
4878
4878
  }
4879
4879
  return own2;
4880
4880
  }
@@ -25467,7 +25467,270 @@ function fileStore(path) {
25467
25467
 
25468
25468
  // src/bridge.ts
25469
25469
  import { mkdirSync as mkdirSync4 } from "node:fs";
25470
- import { join as join5 } from "node:path";
25470
+ import { homedir as homedir4 } from "node:os";
25471
+ import { join as join6 } from "node:path";
25472
+
25473
+ // src/context.ts
25474
+ import { createHash as createHash2 } from "node:crypto";
25475
+ import { globSync, readFileSync as readFileSync5, statSync } from "node:fs";
25476
+ import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
25477
+ import { dirname as dirname4, isAbsolute as isAbsolute2, join as join5, relative, sep as sep2 } from "node:path";
25478
+ var AGENTS_MD = "AGENTS.md";
25479
+ function placesOf(env, home3) {
25480
+ const config3 = env["OPENCODE_CONFIG_DIR"] || join5(env["XDG_CONFIG_HOME"] || join5(home3, ".config"), "opencode");
25481
+ return { home: home3, config: config3 };
25482
+ }
25483
+ function hashOf(text2) {
25484
+ return createHash2("sha256").update(text2, "utf8").digest("hex");
25485
+ }
25486
+ function isFile(path) {
25487
+ try {
25488
+ return statSync(path).isFile();
25489
+ } catch {
25490
+ return false;
25491
+ }
25492
+ }
25493
+ function textOf(path) {
25494
+ try {
25495
+ return readFileSync5(path, "utf8");
25496
+ } catch {
25497
+ return void 0;
25498
+ }
25499
+ }
25500
+ var within = (path, dir) => path === dir || path.startsWith(`${dir}${sep2}`);
25501
+ function upTo(dir, stop2) {
25502
+ const dirs = [dir];
25503
+ for (let at = dir; within(at, stop2) && at !== stop2 && dirname4(at) !== at; ) {
25504
+ at = dirname4(at);
25505
+ dirs.push(at);
25506
+ }
25507
+ return dirs;
25508
+ }
25509
+ function namedFiles(sources, folder2, root, home3) {
25510
+ const found = [];
25511
+ const glob = (pattern, cwd) => {
25512
+ try {
25513
+ return globSync(pattern, { cwd }).map((match) => join5(cwd, match));
25514
+ } catch {
25515
+ return [];
25516
+ }
25517
+ };
25518
+ for (const source2 of sources) {
25519
+ if (/^https?:\/\//i.test(source2)) continue;
25520
+ const expanded = source2.startsWith("~/") ? join5(home3, source2.slice(2)) : source2;
25521
+ const matches = isAbsolute2(expanded) ? glob(expanded.slice(dirname4(expanded).length + 1), dirname4(expanded)) : upTo(folder2, within(folder2, root) ? root : folder2).flatMap((dir) => glob(expanded, dir));
25522
+ for (const path of matches.sort()) if (isFile(path) && !found.includes(path)) found.push(path);
25523
+ }
25524
+ return found;
25525
+ }
25526
+ function before(version3, than) {
25527
+ const parts = (each) => each.split(/[.-]/).slice(0, 3).map((part) => Number.parseInt(part, 10) || 0);
25528
+ const [a, b] = [parts(version3), parts(than)];
25529
+ for (let at = 0; at < 3; at += 1) if ((a[at] ?? 0) !== (b[at] ?? 0)) return (a[at] ?? 0) < (b[at] ?? 0);
25530
+ return false;
25531
+ }
25532
+ var READS_NAMED = "2.0.30";
25533
+ var ContextFiles = class {
25534
+ constructor(places, folders) {
25535
+ this.places = places;
25536
+ this.folders = folders;
25537
+ }
25538
+ places;
25539
+ folders;
25540
+ /** `~/chips-opencode/agents/AGENTS.md`, or the whole path where it isn't in the home. */
25541
+ tilde(path) {
25542
+ const { home: home3 } = this.places;
25543
+ return within(path, home3) ? `~${path.slice(home3.length)}` : path;
25544
+ }
25545
+ /** A shared file's name: where it is, a step at a time (`~ › .config › opencode › AGENTS.md`), with no slash. */
25546
+ crumbs(path) {
25547
+ const { home: home3 } = this.places;
25548
+ const steps = within(path, home3) ? ["~", ...relative(home3, path).split(sep2)] : ["/", ...path.split(sep2)];
25549
+ return steps.filter((step) => step !== "").join(" \u203A ").slice(0, 255);
25550
+ }
25551
+ /**
25552
+ * The harness's own set, for agents whose folders read as `readings` says: every agent's
25553
+ * `AGENTS.md`, those above it, the person's own, and the named files not in an agent's folder.
25554
+ */
25555
+ sharedSpots(readings) {
25556
+ const { places, folders } = this;
25557
+ const spots = [];
25558
+ const add = (spot) => {
25559
+ if (!spots.some((each) => each.path === spot.path)) spots.push(spot);
25560
+ };
25561
+ const reaches = (dir) => (folder2) => within(folder2, dir) && within(dir, readings.get(folder2)?.stop ?? folder2);
25562
+ if (folders !== void 0 && within(folders, places.home)) {
25563
+ const path = join5(folders, AGENTS_MD);
25564
+ add({
25565
+ path,
25566
+ name: this.crumbs(path),
25567
+ title: "For every OpenCode agent",
25568
+ about: this.tilde(path),
25569
+ editable: true,
25570
+ id: "every-agent",
25571
+ over: reaches(folders)
25572
+ });
25573
+ }
25574
+ const yours = join5(places.config, AGENTS_MD);
25575
+ for (const [folder2, reading] of readings) {
25576
+ for (const dir of upTo(folder2, reading.stop).slice(1)) {
25577
+ const path = join5(dir, AGENTS_MD);
25578
+ if (path === yours || !isFile(path)) continue;
25579
+ add({
25580
+ path,
25581
+ name: this.crumbs(path),
25582
+ title: `From ${this.tilde(dir)}`.slice(0, 80),
25583
+ about: this.tilde(path),
25584
+ editable: false,
25585
+ whyReadOnly: "It sits above the agents' folders.",
25586
+ id: `above-${hashOf(path).slice(0, 12)}`,
25587
+ over: reaches(dir)
25588
+ });
25589
+ }
25590
+ }
25591
+ if (isFile(yours)) {
25592
+ add({
25593
+ path: yours,
25594
+ name: this.crumbs(yours),
25595
+ title: "For all your OpenCode",
25596
+ about: this.tilde(yours),
25597
+ editable: false,
25598
+ whyReadOnly: "Your own OpenCode reads it too.",
25599
+ id: "yours"
25600
+ });
25601
+ }
25602
+ for (const [folder2, reading] of readings) {
25603
+ for (const path of reading.named) {
25604
+ if (within(path, folder2)) continue;
25605
+ add({
25606
+ path,
25607
+ name: this.crumbs(path),
25608
+ title: "Named in OpenCode's config",
25609
+ about: this.tilde(path),
25610
+ editable: false,
25611
+ whyReadOnly: "OpenCode's config names it for every session.",
25612
+ id: `named-${hashOf(path).slice(0, 12)}`,
25613
+ over: (other) => readings.get(other)?.named.includes(path) === true
25614
+ });
25615
+ }
25616
+ }
25617
+ return spots;
25618
+ }
25619
+ /** An agent's own files: its folder's `AGENTS.md`, and the named files in its folder. */
25620
+ ownSpots(folder2, reading) {
25621
+ const named = reading.named.filter((path) => within(path, folder2) && path !== join5(folder2, AGENTS_MD));
25622
+ return [
25623
+ { path: join5(folder2, AGENTS_MD), name: AGENTS_MD, title: "How it works", editable: true },
25624
+ ...named.map((path) => ({
25625
+ path,
25626
+ name: relative(folder2, path).split(sep2).join(" \u203A ").slice(0, 255),
25627
+ title: "Named in OpenCode's config",
25628
+ editable: true
25629
+ }))
25630
+ ];
25631
+ }
25632
+ /** The shared files an agent in `folder` reads. */
25633
+ readBy(folder2, reading) {
25634
+ return this.sharedSpots(/* @__PURE__ */ new Map([[folder2, reading]])).filter((spot) => spot.over === void 0 || spot.over(folder2));
25635
+ }
25636
+ /** An agent's copy: its own files and the shared ones it reads, eight at most. */
25637
+ agentCopy(folder2, reading) {
25638
+ const rows = [];
25639
+ const notShown = [];
25640
+ const add = (row, text2) => {
25641
+ const named = { name: row["name"], title: row["title"] };
25642
+ if (text2 !== void 0 && contextBytes(text2) > CONTEXT_FILE_BYTES) notShown.push({ ...named, reason: "too_large" });
25643
+ else if (rows.length >= CONTEXT_FILES_SHOWN) notShown.push({ ...named, reason: "count" });
25644
+ else rows.push(row);
25645
+ };
25646
+ for (const spot of this.ownSpots(folder2, reading)) {
25647
+ const text2 = textOf(spot.path) ?? "";
25648
+ add({ name: spot.name, title: spot.title, role: "instructions", text: text2, hash: hashOf(text2), editable: true }, text2);
25649
+ }
25650
+ for (const spot of this.readBy(folder2, reading)) {
25651
+ add({
25652
+ name: spot.name,
25653
+ title: spot.title,
25654
+ role: "instructions",
25655
+ ...spot.about === void 0 ? {} : { about: spot.about },
25656
+ shared: spot.id,
25657
+ replaceable: false,
25658
+ editable: false
25659
+ });
25660
+ }
25661
+ return { files: rows, ...notShown.length === 0 ? {} : { not_shown: notShown } };
25662
+ }
25663
+ /** The harness's own set, each file with how many of its agents (by their folders' readings) read it. */
25664
+ harnessCopy(readings, agentFolders) {
25665
+ const shared = [];
25666
+ const notShown = [];
25667
+ for (const spot of this.sharedSpots(readings)) {
25668
+ const text2 = textOf(spot.path) ?? "";
25669
+ const named = { name: spot.name, title: spot.title };
25670
+ if (contextBytes(text2) > CONTEXT_FILE_BYTES) {
25671
+ notShown.push({ ...named, reason: "too_large" });
25672
+ continue;
25673
+ }
25674
+ if (shared.length >= CONTEXT_FILES_SHOWN) {
25675
+ notShown.push({ ...named, reason: "count" });
25676
+ continue;
25677
+ }
25678
+ const over = spot.over;
25679
+ shared.push({
25680
+ id: spot.id,
25681
+ name: spot.name,
25682
+ title: spot.title,
25683
+ role: "instructions",
25684
+ ...spot.about === void 0 ? {} : { about: spot.about },
25685
+ text: text2,
25686
+ hash: hashOf(text2),
25687
+ editable: spot.editable,
25688
+ ...spot.whyReadOnly === void 0 ? {} : { why_read_only: spot.whyReadOnly },
25689
+ used_by: over === void 0 ? agentFolders.length : agentFolders.filter(over).length
25690
+ });
25691
+ }
25692
+ return { shared, ...notShown.length === 0 ? {} : { not_shown: notShown } };
25693
+ }
25694
+ /**
25695
+ * A person's edit of one of an agent's files, saved where OpenCode reads it, only if it is still
25696
+ * the version they changed. Refused, with the copy as it is, for a file that isn't the agent's own
25697
+ * to change, and as `changed` for one changed since.
25698
+ */
25699
+ async saveAgent(folder2, reading, edit) {
25700
+ const now = () => this.agentCopy(folder2, reading);
25701
+ const spot = this.ownSpots(folder2, reading).find((each) => each.name === edit.name);
25702
+ if (spot === void 0) {
25703
+ const shared = this.readBy(folder2, reading).some((each) => each.name === edit.name);
25704
+ throw new Refuse(
25705
+ shared ? "Every agent reads this one. Change it on OpenCode's page." : `There is no file called ${edit.name} here.`,
25706
+ now(),
25707
+ "failed"
25708
+ );
25709
+ }
25710
+ if (edit.base_hash === null) throw new Refuse("This file is the agent's own already.", now(), "failed");
25711
+ await this.write(spot, edit, now);
25712
+ }
25713
+ /** A person's edit of one of the harness's own files, read by its agents beside their own. */
25714
+ async saveShared(readings, agentFolders, edit) {
25715
+ const now = () => this.harnessCopy(readings, agentFolders);
25716
+ const spot = this.sharedSpots(readings).find((each) => each.name === edit.name);
25717
+ if (spot === void 0) throw new Refuse(`There is no file called ${edit.name} here.`, now(), "failed");
25718
+ if (!spot.editable) throw new Refuse(spot.whyReadOnly ?? "It can't be changed from Chips.", now(), "failed");
25719
+ if (edit.base_hash === null) {
25720
+ throw new Refuse("Every agent reads this file beside its own, so none can have its own in its place.", now(), "failed");
25721
+ }
25722
+ await this.write(spot, edit, now);
25723
+ }
25724
+ async write(spot, edit, now) {
25725
+ if (hashOf(textOf(spot.path) ?? "") !== edit.base_hash) throw new Refuse("", now(), "changed");
25726
+ try {
25727
+ await mkdir2(dirname4(spot.path), { recursive: true });
25728
+ await writeFile2(spot.path, edit.text, "utf8");
25729
+ } catch (error62) {
25730
+ throw new Refuse(`It couldn't be saved: ${error62 instanceof Error ? error62.message : String(error62)}`.slice(0, 300), now(), "failed");
25731
+ }
25732
+ }
25733
+ };
25471
25734
 
25472
25735
  // src/opencode.ts
25473
25736
  var OpenCodeError = class extends Error {
@@ -25507,12 +25770,47 @@ var OpenCodeClient = class {
25507
25770
  return false;
25508
25771
  }
25509
25772
  }
25773
+ /** Which OpenCode answers: `GET /api/info`'s `version`, `2.0.25`. */
25774
+ async version() {
25775
+ const info = await this.call("GET", "/api/info");
25776
+ return typeof info["version"] === "string" ? info["version"] : "";
25777
+ }
25778
+ /** The root of the project a folder is in, as OpenCode has it: the folder itself, outside any repository. */
25779
+ async projectRoot(directory) {
25780
+ const read = await this.call("GET", `/api/location${whereIs(directory)}`);
25781
+ const project = read["project"] ?? {};
25782
+ return typeof project["directory"] === "string" ? project["directory"] : directory;
25783
+ }
25784
+ /**
25785
+ * The instruction files the config documents for a folder name (`instructions`), each document's
25786
+ * in turn, each named once: a path, a glob or a URL, as written.
25787
+ */
25788
+ async instructions(directory) {
25789
+ const answer = await this.fetcher(`${this.root}/api/config${whereIs(directory)}`, { headers: { authorization: this.auth } });
25790
+ if (!answer.ok) throw await failureOf(answer);
25791
+ const entries = await answer.json();
25792
+ const named = (Array.isArray(entries) ? entries : []).filter((entry) => entry["type"] === "document").flatMap((entry) => (entry["info"] ?? {})["instructions"]).filter((each) => typeof each === "string");
25793
+ return [...new Set(named)];
25794
+ }
25510
25795
  async agents() {
25511
25796
  return await this.catalogue("agent");
25512
25797
  }
25513
25798
  async models() {
25514
25799
  return await this.catalogue("model");
25515
25800
  }
25801
+ /** The model a session with none of its own runs on, when the server has one. */
25802
+ async defaultModel() {
25803
+ const read = await this.call("GET", `/api/model/default${this.where()}`);
25804
+ const data = read["data"];
25805
+ return typeof data === "object" && data !== null ? data : void 0;
25806
+ }
25807
+ /**
25808
+ * The model a session runs on from its next model call. OpenCode takes any model and level
25809
+ * here, one it can't run among them, so the bridge asks only for those of its catalogue.
25810
+ */
25811
+ async setModel(sessionId, model) {
25812
+ await this.call("POST", `/api/session/${encodeURIComponent(sessionId)}/model`, { model });
25813
+ }
25516
25814
  /**
25517
25815
  * A project's catalogue. OpenCode 2.0 answers the first ask for a project it hasn't loaded with
25518
25816
  * an empty list, and the next with the whole one: a server always has its `build` agent, so an
@@ -25577,6 +25875,13 @@ var OpenCodeClient = class {
25577
25875
  if (typeof id2 !== "string") throw new OpenCodeError("OpenCode took the words and named no inbox item", 502);
25578
25876
  return id2;
25579
25877
  }
25878
+ /**
25879
+ * Compact a session: its inbox takes the ask, which starts at once when it is idle and at its next
25880
+ * step when it is working. `session.compaction.started` and `.ended` or `.failed` say how it goes.
25881
+ */
25882
+ async compact(sessionId) {
25883
+ await this.call("POST", `/api/session/${encodeURIComponent(sessionId)}/compact`, {});
25884
+ }
25580
25885
  /** Words the session hasn't taken yet, taken back. */
25581
25886
  async withdraw(sessionId, item) {
25582
25887
  await this.call("DELETE", `/api/session/${encodeURIComponent(sessionId)}/inbox/${encodeURIComponent(item)}`);
@@ -25626,6 +25931,22 @@ var OpenCodeClient = class {
25626
25931
  const content = Array.isArray(newest?.["content"]) ? newest["content"] : [];
25627
25932
  return content.filter((part) => part["type"] === "text" && typeof part["text"] === "string").map((part) => String(part["text"])).join("\n\n");
25628
25933
  }
25934
+ /** The tokens and model of a session's newest model call, and when: nothing for a session with none. */
25935
+ async lastCall(sessionId) {
25936
+ const read = await this.call(
25937
+ "GET",
25938
+ `/api/session/${encodeURIComponent(sessionId)}/message?type=assistant&order=desc&limit=1`
25939
+ );
25940
+ const newest = (Array.isArray(read["data"]) ? read["data"] : [])[0];
25941
+ const tokens2 = newest?.["tokens"];
25942
+ if (typeof tokens2 !== "object" || tokens2 === null) return void 0;
25943
+ const time3 = newest?.["time"] ?? {};
25944
+ return {
25945
+ tokens: tokens2,
25946
+ ...typeof newest?.["model"] === "object" && newest["model"] !== null ? { model: newest["model"] } : {},
25947
+ at: typeof time3["created"] === "number" ? time3["created"] : 0
25948
+ };
25949
+ }
25629
25950
  /** Every event the server sends, each handed to `hear` as it comes, until the stream ends. */
25630
25951
  async events(hear, signal) {
25631
25952
  const answer = await this.fetcher(`${this.root}/api/event`, {
@@ -25649,7 +25970,7 @@ var OpenCodeClient = class {
25649
25970
  /** `?location[directory]=…` for the catalogues, which are per project. */
25650
25971
  where() {
25651
25972
  const directory = this.address.directory;
25652
- return directory === void 0 ? "" : `?location%5Bdirectory%5D=${encodeURIComponent(directory)}`;
25973
+ return directory === void 0 ? "" : whereIs(directory);
25653
25974
  }
25654
25975
  async call(method, path, body) {
25655
25976
  const answer = await this.fetcher(`${this.root}${path}`, {
@@ -25665,6 +25986,9 @@ var OpenCodeClient = class {
25665
25986
  return text2 === "" ? {} : JSON.parse(text2);
25666
25987
  }
25667
25988
  };
25989
+ function whereIs(directory) {
25990
+ return `?location%5Bdirectory%5D=${encodeURIComponent(directory)}`;
25991
+ }
25668
25992
  function eventOf(block) {
25669
25993
  const data = block.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).replace(/^ /, "")).join("\n");
25670
25994
  if (data === "") return void 0;
@@ -25696,18 +26020,138 @@ async function failureOf(answer) {
25696
26020
  return new OpenCodeError(message, answer.status, tag, kind);
25697
26021
  }
25698
26022
 
26023
+ // src/models.ts
26024
+ var MODEL_CHOICES_MAX2 = 40;
26025
+ var EFFORT_LEVELS_MAX2 = 8;
26026
+ var LEVEL_WORDS = {
26027
+ none: "None",
26028
+ minimal: "Minimal",
26029
+ low: "Low",
26030
+ medium: "Medium",
26031
+ high: "High",
26032
+ xhigh: "Extra high",
26033
+ max: "Max",
26034
+ thinking: "Thinking"
26035
+ };
26036
+ function writtenOf(model) {
26037
+ return `${model.providerID}/${model.id}`;
26038
+ }
26039
+ function refOf(pick2) {
26040
+ return { ...modelRefOf(pick2.model), ...pick2.effort === void 0 ? {} : { variant: pick2.effort } };
26041
+ }
26042
+ function levelOf(model) {
26043
+ return model?.variant === void 0 || model.variant === "default" ? void 0 : model.variant;
26044
+ }
26045
+ function sameModel(a, b) {
26046
+ return a?.providerID === b?.providerID && a?.id === b?.id && levelOf(a) === levelOf(b);
26047
+ }
26048
+ function levelWords(id2) {
26049
+ return (LEVEL_WORDS[id2] ?? `${id2.charAt(0).toUpperCase()}${id2.slice(1)}`).slice(0, 40);
26050
+ }
26051
+ function runnable(catalogue) {
26052
+ return catalogue.filter((model) => model.enabled !== false && model.status !== "deprecated");
26053
+ }
26054
+ function levelsOf(model) {
26055
+ const seen = /* @__PURE__ */ new Set();
26056
+ const each = (model?.variants ?? []).filter((variant) => !seen.has(variant.id) && seen.add(variant.id));
26057
+ return each.slice(-EFFORT_LEVELS_MAX2).map((variant) => ({ id: variant.id, label: levelWords(variant.id) }));
26058
+ }
26059
+ var fitting = (choice, effort) => choice?.efforts.some((level) => level.id === effort) === true ? effort : void 0;
26060
+ function modelCopy(input2) {
26061
+ const { pick: pick2, fallback } = input2;
26062
+ const runsOn = pick2 === void 0 ? fallback?.model : refOf(pick2);
26063
+ if (runsOn === void 0) return void 0;
26064
+ const offered2 = runnable(input2.catalogue);
26065
+ const names = /* @__PURE__ */ new Map();
26066
+ for (const model of offered2) names.set(model.name, (names.get(model.name) ?? 0) + 1);
26067
+ const choices = offered2.map((model) => ({
26068
+ id: writtenOf(model),
26069
+ label: ((names.get(model.name) ?? 0) > 1 ? `${model.name} \xB7 ${model.providerID}` : model.name || model.id).slice(0, 80),
26070
+ efforts: levelsOf(model)
26071
+ }));
26072
+ const named = (ref) => {
26073
+ const id2 = writtenOf(ref);
26074
+ const known = choices.find((choice2) => choice2.id === id2);
26075
+ if (known !== void 0) return known;
26076
+ const level = levelOf(ref);
26077
+ const choice = { id: id2, label: ref.id.slice(0, 80), efforts: level === void 0 ? [] : [{ id: level, label: levelWords(level) }] };
26078
+ choices.push(choice);
26079
+ return choice;
26080
+ };
26081
+ const now = named(runsOn);
26082
+ const otherwise = fallback === void 0 ? void 0 : named(fallback.model);
26083
+ const effort = fitting(now, levelOf(runsOn));
26084
+ const fallbackEffort = fitting(otherwise, levelOf(fallback?.model));
26085
+ const keep = /* @__PURE__ */ new Set([now.id, ...otherwise === void 0 ? [] : [otherwise.id]]);
26086
+ let others = MODEL_CHOICES_MAX2 - keep.size;
26087
+ return {
26088
+ model: now.id,
26089
+ ...effort === void 0 ? {} : { effort },
26090
+ own: pick2 !== void 0,
26091
+ ...fallback === void 0 || otherwise === void 0 ? {} : {
26092
+ default: {
26093
+ label: fallback.label.slice(0, 80),
26094
+ model: otherwise.id,
26095
+ ...fallbackEffort === void 0 ? {} : { effort: fallbackEffort }
26096
+ }
26097
+ },
26098
+ choices: choices.filter((choice) => keep.has(choice.id) || others-- > 0),
26099
+ editable: true
26100
+ };
26101
+ }
26102
+ function pickOf(change, catalogue) {
26103
+ if (change.model === null) return null;
26104
+ const model = runnable(catalogue).find((each) => writtenOf(each) === change.model);
26105
+ if (model === void 0) throw new Error(`OpenCode doesn't run ${change.model} here.`);
26106
+ if (change.effort === null) return { model: change.model };
26107
+ if (!(model.variants ?? []).some((variant) => variant.id === change.effort)) {
26108
+ throw new Error(`${model.name || model.id} has no ${levelWords(change.effort)} level.`);
26109
+ }
26110
+ return { model: change.model, effort: change.effort };
26111
+ }
26112
+
26113
+ // src/windows.ts
26114
+ var OWN_LIMIT = "OpenCode compacts it itself when it is nearly full, as its config says.";
26115
+ function usedOf(tokens2) {
26116
+ return tokens2.input + tokens2.cache.read + tokens2.cache.write + tokens2.output + tokens2.reasoning;
26117
+ }
26118
+ function compactedTo(data) {
26119
+ const tokens2 = data["tokens"];
26120
+ const summary = typeof tokens2?.output === "number" ? tokens2.output : 0;
26121
+ const recent = typeof data["recent"] === "string" ? data["recent"] : "";
26122
+ return Math.round(summary + recent.length / 4);
26123
+ }
26124
+ function windowCopy(kept, compacting) {
26125
+ return {
26126
+ used: Math.max(0, Math.round(kept.used)),
26127
+ window: Math.max(1, Math.round(kept.window)),
26128
+ ...kept.model === void 0 ? {} : { model: kept.model.slice(0, 120) },
26129
+ as_of: kept.asOf,
26130
+ ...compacting === void 0 ? {} : { compacting },
26131
+ ...kept.last === void 0 ? {} : { last: kept.last },
26132
+ ...kept.longTurn === void 0 ? {} : { long_turn: { ...kept.longTurn, folds: Math.min(kept.longTurn.folds, LONG_TURN_FOLDS_MAX) } },
26133
+ compacts: true,
26134
+ editable: false,
26135
+ why_read_only: OWN_LIMIT
26136
+ };
26137
+ }
26138
+
25699
26139
  // src/bridge.ts
25700
- var WANTS = ["agents", "new_agents", "forks"];
26140
+ var WANTS = ["agents", "new_spaces", "new_agents", "forks", "models", "context_files", "context_windows"];
26141
+ function wantsFor(ownServer2) {
26142
+ return ownServer2 ? [...WANTS, "restarts"] : WANTS;
26143
+ }
25701
26144
  function freeFolder(root, name, taken) {
25702
26145
  const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
25703
26146
  for (let count = 1; ; count += 1) {
25704
- const folder2 = join5(root, count === 1 ? slug : `${slug}-${count}`);
26147
+ const folder2 = join6(root, count === 1 ? slug : `${slug}-${count}`);
25705
26148
  if (!taken.includes(folder2)) return folder2;
25706
26149
  }
25707
26150
  }
25708
26151
  var ids = createIdGenerator();
25709
26152
  var QUIET_MS = 1500;
25710
26153
  var INTERRUPTING = "interrupting";
26154
+ var TROUBLE_CHARS = 200;
25711
26155
  var LOOKS_KEPT = 500;
25712
26156
  var REPLY_CHARS = 1500;
25713
26157
  var KINDS = {
@@ -25731,6 +26175,9 @@ var Bridge = class {
25731
26175
  constructor(options) {
25732
26176
  this.options = options;
25733
26177
  this.log = options.log ?? (() => void 0);
26178
+ this.places = options.places ?? placesOf(process.env, homedir4());
26179
+ const known = options.folders !== void 0 || options.directory !== void 0;
26180
+ this.files = known ? new ContextFiles(this.places, options.folders) : void 0;
25734
26181
  }
25735
26182
  options;
25736
26183
  agents = [];
@@ -25760,6 +26207,21 @@ var Bridge = class {
25760
26207
  health;
25761
26208
  listening = new AbortController();
25762
26209
  connections = 0;
26210
+ /** Opens the event stream again now, rather than once its wait is over. */
26211
+ wake;
26212
+ /** A person's Restart under way: nothing is said of how it is meanwhile (Chips ADR 0094). */
26213
+ restarting;
26214
+ /** Why OpenCode's server isn't there, where the bridge knows more than that it doesn't answer. */
26215
+ why;
26216
+ /** The files OpenCode builds each agent from, where the bridge knows each agent's folder. */
26217
+ files;
26218
+ places;
26219
+ /** Each folder's project root, as OpenCode said it. */
26220
+ roots = /* @__PURE__ */ new Map();
26221
+ /** Whether the server reads the files its config's `instructions` names, asked once a start or a restart. */
26222
+ readsNamed;
26223
+ /** Agents compacting now, by whose sessions they are: waiting for their session's next step, or under way, and the ask from Chips. */
26224
+ compacting = /* @__PURE__ */ new Map();
25763
26225
  /** Register the agents, start hearing OpenCode and what is said in Chips, and say how it is. */
25764
26226
  async start() {
25765
26227
  const { chips: chips2 } = this.options;
@@ -25779,9 +26241,17 @@ var Bridge = class {
25779
26241
  chips2.on("agent.fork", (fork) => {
25780
26242
  this.copy(fork).catch((error62) => this.log(`a copy went wrong: ${messageOf(error62)}`));
25781
26243
  });
26244
+ chips2.onSpaceAsked((asked) => {
26245
+ this.claim(asked).catch((error62) => this.log(`a space from the + went wrong: ${messageOf(error62)}`));
26246
+ });
25782
26247
  chips2.on("feedback", (feedback2) => {
25783
26248
  this.notRight(feedback2).catch((error62) => this.log(`a second look went wrong: ${messageOf(error62)}`));
25784
26249
  });
26250
+ for (const agent of await chips2.agents.list().catch(() => [])) {
26251
+ if (agent.state !== "archived") await this.sendModel(agent.agentId);
26252
+ }
26253
+ await this.sendContexts();
26254
+ await this.sendWindows();
25785
26255
  await this.sayHowItIs();
25786
26256
  this.health = setInterval(() => void this.sayHowItIs(), this.options.healthEveryMs ?? 3e4);
25787
26257
  this.health.unref();
@@ -25790,6 +26260,38 @@ var Bridge = class {
25790
26260
  if (this.health !== void 0) clearInterval(this.health);
25791
26261
  this.listening.abort();
25792
26262
  }
26263
+ /**
26264
+ * A person's Restart (Chips ADR 0094): the server stopped and started again, then how it is,
26265
+ * fine or why not. Asked again meanwhile, it is the same restart. Nothing where the bridge
26266
+ * doesn't run the server, which never says it hears restarts.
26267
+ */
26268
+ restart() {
26269
+ this.restarting ??= (async () => {
26270
+ try {
26271
+ await this.restartServer();
26272
+ } finally {
26273
+ this.restarting = void 0;
26274
+ }
26275
+ await this.sayHowItIs();
26276
+ })();
26277
+ return this.restarting;
26278
+ }
26279
+ async restartServer() {
26280
+ const again2 = this.options.restartServer;
26281
+ if (again2 === void 0) return;
26282
+ this.log("a person asked for a restart");
26283
+ try {
26284
+ await again2();
26285
+ this.why = void 0;
26286
+ this.readsNamed = void 0;
26287
+ this.wake?.();
26288
+ } catch (error62) {
26289
+ const why = messageOf(error62).replace(/\s+/g, " ").trim();
26290
+ this.why = why.length <= TROUBLE_CHARS ? why : `${why.slice(0, TROUBLE_CHARS - 1)}\u2026`;
26291
+ this.log(`OpenCode's server isn't back: ${why}`);
26292
+ for (const turn of this.turns.values()) turn.end({ outcome: "failed", why: "OpenCode didn't come back after a restart" });
26293
+ }
26294
+ }
25793
26295
  /** The Main agent, the first registered, once started: who says the harness's first words. */
25794
26296
  get mainAgentId() {
25795
26297
  return this.agents[0]?.agentId;
@@ -25803,16 +26305,16 @@ var Bridge = class {
25803
26305
  */
25804
26306
  async seatMain(config3, known) {
25805
26307
  const { chips: chips2 } = this.options;
25806
- const before = known.find((agent) => agent.lead);
26308
+ const before2 = known.find((agent) => agent.lead);
25807
26309
  const made = known.some((agent) => agent.key === chips2.harnessId);
25808
- if (!made && before !== void 0) {
25809
- await this.goHome(before, known);
25810
- return before;
26310
+ if (!made && before2 !== void 0) {
26311
+ await this.goHome(before2, known);
26312
+ return before2;
25811
26313
  }
25812
26314
  const main2 = await chips2.agents.register({ key: chips2.harnessId, name: config3.name, lead: true });
25813
26315
  await this.goHome(main2, known);
25814
- if (before !== void 0 && before.agentId !== main2.agentId) {
25815
- await chips2.agents.register({ key: before.key, name: before.name, state: "archived" }).catch((error62) => this.log(`${before.name} stays: ${String(error62)}`));
26316
+ if (before2 !== void 0 && before2.agentId !== main2.agentId) {
26317
+ await chips2.agents.register({ key: before2.key, name: before2.name, state: "archived" }).catch((error62) => this.log(`${before2.name} stays: ${String(error62)}`));
25816
26318
  }
25817
26319
  return main2;
25818
26320
  }
@@ -25823,11 +26325,17 @@ var Bridge = class {
25823
26325
  const there = known.find((agent) => agent.agentId !== main2.agentId && agent.spaces.includes(home3));
25824
26326
  await chips2.agents.attach(main2.agentId, { spaceId: home3, replacing: there?.agentId ?? chips2.harnessId }).catch((error62) => this.log(`${main2.name} stays out of its home: ${String(error62)}`));
25825
26327
  }
25826
- /** Trouble while the server doesn't answer, and fine once it does (ADR 0083). */
26328
+ /**
26329
+ * Trouble while the server doesn't answer, and fine once it does (ADR 0083). Nothing while a
26330
+ * restart is under way: its end says (ADR 0094).
26331
+ */
25827
26332
  async sayHowItIs() {
26333
+ if (this.restarting !== void 0) return;
25828
26334
  const up = await this.options.opencode.healthy();
26335
+ if (this.restarting !== void 0) return;
26336
+ if (up) this.why = void 0;
25829
26337
  const chips2 = this.options.chips;
25830
- await (up ? chips2.reportOk() : chips2.reportTrouble("OpenCode isn't answering")).catch(() => void 0);
26338
+ await (up ? chips2.reportOk() : chips2.reportTrouble(this.why ?? "OpenCode isn't answering")).catch(() => void 0);
25831
26339
  }
25832
26340
  /** OpenCode's event stream, opened again whenever it drops, a little later each time it fails. */
25833
26341
  async listen() {
@@ -25841,8 +26349,15 @@ var Bridge = class {
25841
26349
  if (this.listening.signal.aborted) return;
25842
26350
  this.log(`OpenCode's events: ${messageOf(error62)}`);
25843
26351
  }
25844
- await new Promise((resolve5) => setTimeout(resolve5, wait2));
25845
- wait2 = Math.min(wait2 * 2, 15e3);
26352
+ const woken = await new Promise((resolve5) => {
26353
+ const timer = setTimeout(() => resolve5(false), wait2);
26354
+ this.wake = () => {
26355
+ clearTimeout(timer);
26356
+ resolve5(true);
26357
+ };
26358
+ });
26359
+ this.wake = void 0;
26360
+ wait2 = woken ? first : Math.min(wait2 * 2, 15e3);
25846
26361
  }
25847
26362
  }
25848
26363
  /**
@@ -25861,6 +26376,47 @@ var Bridge = class {
25861
26376
  });
25862
26377
  this.ours.add(agent.agentId);
25863
26378
  this.log(`${agent.name} made${agent.attached === false ? `, not in the room: ${agent.why ?? "too late"}` : ""}`);
26379
+ await this.sendModel(agent.agentId);
26380
+ await this.sendContexts();
26381
+ }
26382
+ /**
26383
+ * A space a person asked for from Chips's + (Chips ADR 0241), or beside another harness: claimed
26384
+ * under a key of its own, named from its first words, and Chips makes the new agent the key is
26385
+ * for and puts it in the space. That agent answers the first words, which are the ask's and are
26386
+ * not heard again as a message, as the first OpenCode agent, in a session and a folder of its
26387
+ * own. A claim heard again finds the space claimed already, and the agent the same.
26388
+ */
26389
+ async claim(asked) {
26390
+ const { chips: chips2 } = this.options;
26391
+ const { spaceId, firstMessage } = asked;
26392
+ const key = `opencode:space:${spaceId}`;
26393
+ await chips2.claimSpace(spaceId, key, titleFrom(firstMessage.text));
26394
+ const agent = (await chips2.agents.list()).find((each) => each.key === key);
26395
+ if (agent === void 0) {
26396
+ this.log(`claimed ${spaceId}, and Chips put no agent there`);
26397
+ return;
26398
+ }
26399
+ this.ours.add(agent.agentId);
26400
+ this.log(`${agent.name} claimed ${spaceId}`);
26401
+ await this.sendModel(agent.agentId);
26402
+ await this.sendContexts();
26403
+ const context = await chips2.roomContext(spaceId, { limit: 20 }).catch(() => void 0);
26404
+ const said = context?.lines.find((line) => line.message_id === firstMessage.messageId)?.from;
26405
+ const message = {
26406
+ type: "message.new",
26407
+ space_id: spaceId,
26408
+ message_id: firstMessage.messageId,
26409
+ text: firstMessage.text,
26410
+ from: {
26411
+ participant_id: asked.askedBy,
26412
+ kind: "person",
26413
+ display_name: said?.name ?? "Someone",
26414
+ role: said?.role ?? "member",
26415
+ ...said?.workspace_role === void 0 ? {} : { workspace_role: said.workspace_role }
26416
+ },
26417
+ for: [agent.agentId]
26418
+ };
26419
+ await this.hear(message);
25864
26420
  }
25865
26421
  /** A person's Fork, made once however often it is heard at a time. */
25866
26422
  copy(fork) {
@@ -25922,6 +26478,8 @@ var Bridge = class {
25922
26478
  this.ours.add(copy.agentId);
25923
26479
  if (kept.session !== void 0) state2.sessions[`${copy.agentId}|${spaceId}`] = kept.session;
25924
26480
  if (kept.folder !== void 0) state2.folders = { ...state2.folders, [copy.agentId]: kept.folder };
26481
+ const pick2 = state2.models?.[who];
26482
+ if (pick2 !== void 0) state2.models = { ...state2.models, [copy.agentId]: pick2 };
25925
26483
  saveState(state2);
25926
26484
  if (copied !== void 0) {
25927
26485
  await opencode2.update(copied.id, {
@@ -25932,6 +26490,7 @@ var Bridge = class {
25932
26490
  this.log(
25933
26491
  `${copy.name}, a copy of ${name}, made${kept.session === void 0 ? " new" : ` from ${kept.session}`}${copy.attached === false && copy.why !== void 0 ? `; not in the room: ${copy.why}` : ""}`
25934
26492
  );
26493
+ void this.sendModel(copy.agentId).then(() => this.sendContexts());
25935
26494
  }
25936
26495
  /** The session of an agent's to copy: its room's, or where it is in none, the last it had. */
25937
26496
  sessionToCopy(who, room) {
@@ -25939,6 +26498,304 @@ var Bridge = class {
25939
26498
  if (room !== void 0 && room !== null) return sessions[`${who}|${room}`];
25940
26499
  return Object.entries(sessions).findLast(([id2]) => id2.startsWith(`${who}|`))?.[1];
25941
26500
  }
26501
+ // ------------------------------------------------------------------ what a person changes from an agent's page
26502
+ /**
26503
+ * Which OpenCode agent a Chips agent of the harness's runs as, and whose its sessions and its
26504
+ * pick are, as `roomOf` says: the key of one configured here, else its Chips id. Nothing for an
26505
+ * agent not the harness's.
26506
+ */
26507
+ async whoseIs(agentId2) {
26508
+ const agent = await this.runnerOf(agentId2);
26509
+ if (agent === void 0 || agentId2 === this.options.chips.harnessId) return void 0;
26510
+ return { agent, who: agentId2 === agent.agentId ? agent.key : agentId2 };
26511
+ }
26512
+ /**
26513
+ * What an agent runs on with no pick of its own: its model in the bridge's config, else its
26514
+ * OpenCode agent's, else the server's default.
26515
+ */
26516
+ async fallbackOf(agent) {
26517
+ if (agent.model !== void 0) return { model: agent.model, label: "As in config.json" };
26518
+ const { opencode: opencode2 } = this.options;
26519
+ const own2 = (await opencode2.agents().catch(() => [])).find((each) => each.id === agent.config.agent)?.model;
26520
+ if (own2 !== void 0) return { model: own2, label: "OpenCode's default" };
26521
+ const model = await opencode2.defaultModel();
26522
+ return model === void 0 ? void 0 : { model: { providerID: model.providerID, id: model.id }, label: "OpenCode's default" };
26523
+ }
26524
+ /** An agent's model, as OpenCode and the bridge have it now: nothing for an agent not the harness's. */
26525
+ async modelCopyOf(agentId2) {
26526
+ const whose = await this.whoseIs(agentId2);
26527
+ if (whose === void 0) return void 0;
26528
+ const [catalogue, fallback] = await Promise.all([this.options.opencode.models(), this.fallbackOf(whose.agent)]);
26529
+ return modelCopy({ catalogue, pick: this.options.state.models?.[whose.who], fallback });
26530
+ }
26531
+ /** An agent's model sent, where it changed since it last was (Chips takes the same copy once). */
26532
+ async sendModel(agentId2) {
26533
+ try {
26534
+ const copy = await this.modelCopyOf(agentId2);
26535
+ if (copy !== void 0) await this.options.chips.settings.model(agentId2, copy);
26536
+ } catch (error62) {
26537
+ this.log(`the model of ${agentId2} not sent: ${messageOf(error62)}`);
26538
+ }
26539
+ }
26540
+ /**
26541
+ * A person's pick of an agent's model and level from its page (Chips ADR 0241): kept with the
26542
+ * bridge's state, and given to each of its sessions before its next message there. Only a model
26543
+ * OpenCode runs, at one of its levels; `null` is its default again. Answered with its copy.
26544
+ */
26545
+ async modelChanged(change) {
26546
+ const whose = await this.whoseIs(change.agent_id);
26547
+ if (whose === void 0) throw new Refuse("This harness has no such agent.");
26548
+ const { opencode: opencode2, state: state2, saveState } = this.options;
26549
+ let pick2;
26550
+ try {
26551
+ pick2 = pickOf(change, await opencode2.models());
26552
+ } catch (error62) {
26553
+ throw new Refuse(messageOf(error62), await this.modelCopyOf(change.agent_id).catch(() => void 0));
26554
+ }
26555
+ const models = { ...state2.models };
26556
+ if (pick2 === null) delete models[whose.who];
26557
+ else models[whose.who] = pick2;
26558
+ state2.models = models;
26559
+ saveState(state2);
26560
+ const name = await this.nameOf(whose);
26561
+ this.log(`${name} runs on ${pick2 === null ? "its default" : `${pick2.model}${pick2.effort === void 0 ? "" : ` at ${pick2.effort}`}`}, from Chips`);
26562
+ const copy = await this.modelCopyOf(change.agent_id);
26563
+ if (copy === void 0) throw new Refuse("OpenCode names no model for it.");
26564
+ return copy;
26565
+ }
26566
+ /**
26567
+ * The session a message goes to, put on the model its agent runs on, where it isn't on it
26568
+ * already (Chips ADR 0241): its pick, else its model in the bridge's config. A session that was
26569
+ * put on another before, with neither now, goes back to its OpenCode agent's or the server's.
26570
+ */
26571
+ async modelled(sessionId, { who, agent }) {
26572
+ const { opencode: opencode2, state: state2 } = this.options;
26573
+ const pick2 = state2.models?.[who];
26574
+ try {
26575
+ const has = (await opencode2.session(sessionId))["model"];
26576
+ const want = pick2 !== void 0 ? refOf(pick2) : agent.model ?? (has === void 0 ? void 0 : (await this.fallbackOf(agent))?.model);
26577
+ if (want === void 0 || sameModel(want, has)) return;
26578
+ await opencode2.setModel(sessionId, want);
26579
+ this.log(`${sessionId} runs on ${writtenOf(want)}${want.variant === void 0 ? "" : ` at ${want.variant}`} from now`);
26580
+ } catch (error62) {
26581
+ if (!(error62 instanceof OpenCodeError && error62.tag === "SessionNotFoundError")) {
26582
+ this.log(`${sessionId} stays on its model: ${messageOf(error62)}`);
26583
+ }
26584
+ }
26585
+ }
26586
+ /** The folder an agent's sessions work in, its own or the one project's; none where the bridge knows neither. */
26587
+ async agentFolder(whose) {
26588
+ return await this.folderFor(whose) ?? this.options.directory;
26589
+ }
26590
+ /**
26591
+ * What OpenCode reads for a folder beside its `AGENTS.md` files: where its walk up stops, and
26592
+ * from 2.0.30 the files its config's `instructions` names. A folder whose project OpenCode can't
26593
+ * say is its own project.
26594
+ */
26595
+ async readingOf(folder2) {
26596
+ const { opencode: opencode2 } = this.options;
26597
+ const { home: home3 } = this.places;
26598
+ let root = this.roots.get(folder2);
26599
+ if (root === void 0) {
26600
+ root = await opencode2.projectRoot(folder2).catch(() => folder2);
26601
+ this.roots.set(folder2, root);
26602
+ }
26603
+ this.readsNamed ??= opencode2.version().then(
26604
+ (version3) => !before(version3, READS_NAMED),
26605
+ () => false
26606
+ );
26607
+ const sources = await this.readsNamed ? await opencode2.instructions(folder2).catch(() => []) : [];
26608
+ return { stop: within(folder2, home3) ? home3 : root, named: namedFiles(sources, folder2, root, home3) };
26609
+ }
26610
+ /** Each agent of the harness's, with the folder its sessions work in and what OpenCode reads there. */
26611
+ async agentReadings() {
26612
+ const read = [];
26613
+ for (const agent of await this.options.chips.agents.list().catch(() => [])) {
26614
+ if (agent.state === "archived") continue;
26615
+ const whose = await this.whoseIs(agent.agentId);
26616
+ const folder2 = whose === void 0 ? void 0 : await this.agentFolder(whose);
26617
+ if (folder2 !== void 0) read.push({ agentId: agent.agentId, folder: folder2, reading: await this.readingOf(folder2) });
26618
+ }
26619
+ return read;
26620
+ }
26621
+ /** The harness's own set and each agent's files, as they are on disk now: each sent where it changed. */
26622
+ async sendContexts() {
26623
+ const files = this.files;
26624
+ if (files === void 0) return;
26625
+ try {
26626
+ const read = await this.agentReadings();
26627
+ const readings = new Map(read.map((each) => [each.folder, each.reading]));
26628
+ await this.options.chips.settings.harnessContext(files.harnessCopy(readings, read.map((each) => each.folder)));
26629
+ for (const { agentId: agentId2, folder: folder2, reading } of read) {
26630
+ await this.options.chips.settings.context(agentId2, files.agentCopy(folder2, reading));
26631
+ }
26632
+ } catch (error62) {
26633
+ this.log(`the agents' files not sent: ${messageOf(error62)}`);
26634
+ }
26635
+ }
26636
+ /** One agent's files sent, where they changed. */
26637
+ async sendContext(agentId2) {
26638
+ const whose = await this.whoseIs(agentId2);
26639
+ const folder2 = whose === void 0 ? void 0 : await this.agentFolder(whose);
26640
+ if (this.files === void 0 || folder2 === void 0) return;
26641
+ try {
26642
+ await this.options.chips.settings.context(agentId2, this.files.agentCopy(folder2, await this.readingOf(folder2)));
26643
+ } catch (error62) {
26644
+ this.log(`the files of ${agentId2} not sent: ${messageOf(error62)}`);
26645
+ }
26646
+ }
26647
+ /**
26648
+ * A person's edit of a file from Chips (Chips ADR 0134): saved where OpenCode reads it, only if
26649
+ * it is still the version they changed, and answered with the copy as it is now. OpenCode reads
26650
+ * it again before the agent's next model call, and tells the agent what changed: every agent
26651
+ * working in that folder, or for the harness's own set every agent that reads the file.
26652
+ */
26653
+ async contextEdited(edit) {
26654
+ const files = this.files;
26655
+ if (files === void 0) throw new Refuse("This bridge doesn't know where its agents' files are.");
26656
+ if (edit.agent_id === void 0) {
26657
+ const read = await this.agentReadings();
26658
+ const readings = new Map(read.map((each) => [each.folder, each.reading]));
26659
+ const folders = read.map((each) => each.folder);
26660
+ await files.saveShared(readings, folders, edit);
26661
+ this.log(`OpenCode's ${edit.name} saved from Chips`);
26662
+ return files.harnessCopy(readings, folders);
26663
+ }
26664
+ const whose = await this.whoseIs(edit.agent_id);
26665
+ const folder2 = whose === void 0 ? void 0 : await this.agentFolder(whose);
26666
+ if (whose === void 0 || folder2 === void 0) throw new Refuse("This harness has no such agent.");
26667
+ const reading = await this.readingOf(folder2);
26668
+ await files.saveAgent(folder2, reading, edit);
26669
+ this.log(`${await this.nameOf(whose)}'s ${edit.name} saved from Chips`);
26670
+ for (const other of await this.agentReadings()) {
26671
+ if (other.agentId !== edit.agent_id && other.folder === folder2) void this.sendContext(other.agentId);
26672
+ }
26673
+ return files.agentCopy(folder2, reading);
26674
+ }
26675
+ // ------------------------------------------------------------------ how full each agent is (Chips ADRs 0146, 0241)
26676
+ /** The Chips agent whose sessions are `who`'s: a configured agent's, or the agent itself. */
26677
+ agentIdOf(who) {
26678
+ return this.agents.find((agent) => agent.key === who)?.agentId ?? who;
26679
+ }
26680
+ /** Whose a session is, by the rooms the bridge keeps: nothing for one it didn't make. */
26681
+ whoseSession(sessionId) {
26682
+ const id2 = Object.entries(this.options.state.sessions).find(([, each]) => each === sessionId)?.[0];
26683
+ return id2?.slice(0, id2.lastIndexOf("|"));
26684
+ }
26685
+ /** An agent's window copy sent, and kept with the bridge's state: nothing until it has said how full it is. */
26686
+ async sendWindow(who) {
26687
+ const kept = this.options.state.windows?.[who];
26688
+ if (kept === void 0) return;
26689
+ this.options.saveState(this.options.state);
26690
+ await this.options.chips.settings.contextWindow(this.agentIdOf(who), windowCopy(kept, this.compacting.get(who)?.state)).catch((error62) => this.log(`the context window of ${who} not sent: ${messageOf(error62)}`));
26691
+ }
26692
+ /**
26693
+ * Each agent's window as it last said, at start. One the bridge kept nothing of, from before it
26694
+ * kept them, is read from the newest model call of its sessions.
26695
+ */
26696
+ async sendWindows() {
26697
+ const { opencode: opencode2, state: state2 } = this.options;
26698
+ const whose = new Set(Object.keys(state2.sessions).map((id2) => id2.slice(0, id2.lastIndexOf("|"))));
26699
+ for (const who of whose) {
26700
+ if (state2.windows?.[who] === void 0) {
26701
+ let newest;
26702
+ for (const [id2, sessionId] of Object.entries(state2.sessions)) {
26703
+ if (!id2.startsWith(`${who}|`)) continue;
26704
+ const call = await opencode2.lastCall(sessionId).catch(() => void 0);
26705
+ if (call !== void 0 && (newest === void 0 || call.at > newest.call.at)) newest = { sessionId, call };
26706
+ }
26707
+ const model = newest?.call.model === void 0 ? void 0 : writtenOf(newest.call.model);
26708
+ const window = model === void 0 ? void 0 : await this.windowOf(model);
26709
+ if (newest === void 0 || model === void 0 || window === void 0) continue;
26710
+ const asOf = new Date(newest.call.at > 0 ? newest.call.at : Date.now()).toISOString();
26711
+ state2.windows = { ...state2.windows, [who]: { used: usedOf(newest.call.tokens), window, model, asOf, session: newest.sessionId } };
26712
+ }
26713
+ await this.sendWindow(who);
26714
+ }
26715
+ }
26716
+ /** A turn ended: how full it got, where that passed half the window, and the copy as it is now. */
26717
+ async turnHeld(turn) {
26718
+ const kept = this.options.state.windows?.[turn.who];
26719
+ if (kept === void 0) return;
26720
+ if (turn.peak > kept.window / 2) {
26721
+ kept.longTurn = { at: (/* @__PURE__ */ new Date()).toISOString(), peak: Math.round(turn.peak), folds: turn.folds };
26722
+ }
26723
+ await this.sendWindow(turn.who);
26724
+ }
26725
+ /**
26726
+ * What OpenCode says of a compaction of one of the agents' sessions: under way, done, with how
26727
+ * much it held before and after, or failed. A Compact now asked from Chips that failed is
26728
+ * refused after all, in OpenCode's words.
26729
+ */
26730
+ compactionHeard(sessionId, event) {
26731
+ const who = this.whoseSession(sessionId);
26732
+ const kept = who === void 0 ? void 0 : this.options.state.windows?.[who];
26733
+ if (who === void 0 || kept === void 0) return;
26734
+ const asked = this.compacting.get(who);
26735
+ if (event.type === "session.compaction.started") {
26736
+ if (asked === void 0) this.compacting.set(who, { state: "running" });
26737
+ else asked.state = "running";
26738
+ void this.sendWindow(who);
26739
+ return;
26740
+ }
26741
+ if (event.type === "session.compaction.ended") {
26742
+ this.compacting.delete(who);
26743
+ const to = compactedTo(event.data);
26744
+ const by = event.data["reason"] === "manual" ? "asked" : "itself";
26745
+ kept.last = { at: (/* @__PURE__ */ new Date()).toISOString(), from: Math.round(kept.used), to, by };
26746
+ kept.used = to;
26747
+ kept.asOf = kept.last.at;
26748
+ kept.session = sessionId;
26749
+ const turn = this.turns.get(sessionId);
26750
+ if (turn !== void 0 && by === "itself") turn.folds += 1;
26751
+ this.log(`${who} compacted (${by}), ${kept.last.from} to ${to} tokens`);
26752
+ void this.sendWindow(who);
26753
+ return;
26754
+ }
26755
+ if (event.type === "session.compaction.failed") {
26756
+ this.compacting.delete(who);
26757
+ const error62 = event.data["error"] ?? {};
26758
+ const why = text(error62["message"]) ?? "OpenCode didn't compact it.";
26759
+ this.log(`${who} not compacted: ${why}`);
26760
+ if (asked?.changeId === void 0) {
26761
+ void this.sendWindow(who);
26762
+ return;
26763
+ }
26764
+ void this.options.chips.settings.contextWindowFailed(this.agentIdOf(who), asked.changeId, why, windowCopy(kept)).catch((failed) => this.log(`the context window of ${who} not sent: ${messageOf(failed)}`));
26765
+ }
26766
+ }
26767
+ /**
26768
+ * A person's Compact now (Chips ADR 0146): asked of OpenCode for the session the agent said it
26769
+ * was in last, which takes it at once when idle and at its next step when working, and answered
26770
+ * with the copy saying so. How it goes comes in OpenCode's own events.
26771
+ */
26772
+ async windowCompact(ask2) {
26773
+ const whose = await this.whoseIs(ask2.agent_id);
26774
+ if (whose === void 0) throw new Refuse("This harness has no such agent.");
26775
+ const { who } = whose;
26776
+ const kept = this.options.state.windows?.[who];
26777
+ if (kept === void 0) throw new Refuse(`${await this.nameOf(whose)} hasn't said how full it is yet.`);
26778
+ if (this.compacting.has(who)) throw new Refuse("It is compacting already.", windowCopy(kept, this.compacting.get(who)?.state));
26779
+ const { opencode: opencode2 } = this.options;
26780
+ const busy = this.turns.has(kept.session) || (await opencode2.active().catch(() => /* @__PURE__ */ new Set())).has(kept.session);
26781
+ this.compacting.set(who, { state: busy ? "queued" : "running", changeId: ask2.change_id });
26782
+ try {
26783
+ await opencode2.compact(kept.session);
26784
+ } catch (error62) {
26785
+ this.compacting.delete(who);
26786
+ throw new Refuse(`OpenCode didn't take it: ${messageOf(error62)}`, windowCopy(kept));
26787
+ }
26788
+ this.log(`${await this.nameOf(whose)} compacts ${busy ? "at its session's next step" : "now"}, from Chips`);
26789
+ return windowCopy(kept, this.compacting.get(who)?.state);
26790
+ }
26791
+ /** A person's limit: OpenCode keeps its own, set in its config, and the copy says so. */
26792
+ async windowChange(ask2) {
26793
+ const whose = await this.whoseIs(ask2.agent_id);
26794
+ const kept = whose === void 0 ? void 0 : this.options.state.windows?.[whose.who];
26795
+ if (whose === void 0) throw new Refuse("This harness has no such agent.");
26796
+ const copy = kept === void 0 ? void 0 : windowCopy(kept, this.compacting.get(whose.who)?.state);
26797
+ throw new Refuse(copy?.why_read_only ?? "OpenCode keeps its own limit.", copy);
26798
+ }
25942
26799
  // ------------------------------------------------------------------ what is said to it
25943
26800
  /** A message for some of its agents: each takes a turn in its room, one after another there. */
25944
26801
  async hear(message, own2 = {}) {
@@ -26043,13 +26900,17 @@ var Bridge = class {
26043
26900
  return;
26044
26901
  }
26045
26902
  let end2 = () => void 0;
26046
- const ended = new Promise((resolve5) => {
26903
+ const ended2 = new Promise((resolve5) => {
26047
26904
  end2 = resolve5;
26048
26905
  });
26049
26906
  const turn = {
26050
26907
  run: run2,
26051
26908
  speaker,
26052
26909
  agent,
26910
+ who: room.who,
26911
+ agentId: speakerId ?? agent.agentId,
26912
+ peak: 0,
26913
+ folds: 0,
26053
26914
  sessionId,
26054
26915
  waiting: /* @__PURE__ */ new Set(),
26055
26916
  taken: /* @__PURE__ */ new Set(),
@@ -26076,11 +26937,14 @@ var Bridge = class {
26076
26937
  const words4 = `${steer.from?.display_name ?? "The person"} adds: ${steer.text}`;
26077
26938
  void this.steered(turn, words4, steer.mode);
26078
26939
  });
26079
- const last = await ended;
26940
+ const last = await ended2;
26080
26941
  await turn.chain;
26081
26942
  if (this.turns.get(sessionId) === turn) this.turns.delete(sessionId);
26082
26943
  if (last.outcome === "succeeded") await this.finish(turn);
26083
26944
  else await this.ended(turn, last);
26945
+ await this.sendModel(turn.agentId);
26946
+ await this.sendContext(turn.agentId);
26947
+ await this.turnHeld(turn);
26084
26948
  }
26085
26949
  /** The words into the room's session: the one it had, or a new one when OpenCode no longer has it. */
26086
26950
  async give({ id: id2, who, agent, spaceId }, text2) {
@@ -26092,16 +26956,18 @@ var Bridge = class {
26092
26956
  }
26093
26957
  if (kept !== void 0) {
26094
26958
  try {
26959
+ await this.modelled(kept, { who, agent });
26095
26960
  return { sessionId: kept, given: await this.prompted(kept, text2) };
26096
26961
  } catch (error62) {
26097
26962
  if (!(error62 instanceof OpenCodeError && error62.tag === "SessionNotFoundError")) throw error62;
26098
26963
  this.log(`${agent.config.name}'s session for ${spaceId} is gone from OpenCode: a new one`);
26099
26964
  }
26100
26965
  }
26966
+ const pick2 = state2.models?.[who];
26101
26967
  const made = await opencode2.newSession({
26102
26968
  title: `Chips space ${spaceId}`,
26103
26969
  agent: agent.config.agent,
26104
- model: agent.model,
26970
+ model: pick2 === void 0 ? agent.model : refOf(pick2),
26105
26971
  directory: await this.folderFor({ who, agent }),
26106
26972
  metadata: { chips_space: spaceId, chips_agent: who }
26107
26973
  });
@@ -26238,6 +27104,11 @@ var Bridge = class {
26238
27104
  return;
26239
27105
  }
26240
27106
  if (event.type === "session.forked") return;
27107
+ if (event.type.startsWith("session.compaction.")) {
27108
+ const going = this.turns.get(sessionId);
27109
+ if (going === void 0) this.compactionHeard(sessionId, event);
27110
+ else going.chain = going.chain.then(() => this.compactionHeard(sessionId, event));
27111
+ }
26241
27112
  const own2 = this.turns.get(sessionId);
26242
27113
  if (own2 === void 0 && this.early.has(sessionId)) {
26243
27114
  this.early.get(sessionId)?.push(event);
@@ -26409,6 +27280,9 @@ var Bridge = class {
26409
27280
  const limit = await this.windowOf(model);
26410
27281
  if (limit === void 0) return;
26411
27282
  await turn.run.context({ usedTokens: prompt + written, limitTokens: limit, model }).catch(() => void 0);
27283
+ const windows = this.options.state.windows ??= {};
27284
+ windows[turn.who] = { ...windows[turn.who], used: prompt + written, window: limit, model, asOf: (/* @__PURE__ */ new Date()).toISOString(), session: turn.sessionId };
27285
+ turn.peak = Math.max(turn.peak, prompt + written);
26412
27286
  }
26413
27287
  /** A model's context window, from OpenCode's catalogue, read once (and once more for each it lacked). */
26414
27288
  async windowOf(model) {
@@ -26442,7 +27316,12 @@ var Bridge = class {
26442
27316
  if (decision === void 0 || pending === void 0 || pending.answered) return;
26443
27317
  pending.answered = true;
26444
27318
  if (decision.allowed && toolCall !== void 0) turn.allowed.set(toolCall, approvalId);
26445
- await this.options.opencode.replyPermission(askingSession, requestId, decisionOf(decision), decision.allowed ? void 0 : decision.reason).catch((error62) => this.log(`OpenCode didn't take the answer: ${messageOf(error62)}`));
27319
+ await this.options.opencode.replyPermission(
27320
+ askingSession,
27321
+ requestId,
27322
+ decisionOf(decision, card.action.kind === "shell.exec"),
27323
+ decision.allowed ? void 0 : decision.reason
27324
+ ).catch((error62) => this.log(`OpenCode didn't take the answer: ${messageOf(error62)}`));
26446
27325
  }
26447
27326
  /** A permission answered in OpenCode's own apps: settled in Chips, once, so the card cools (ADR 0079). */
26448
27327
  async settled(turn, data) {
@@ -26580,9 +27459,24 @@ var Bridge = class {
26580
27459
  if (retryable) void this.sayHowItIs();
26581
27460
  }
26582
27461
  };
26583
- function decisionOf(decision) {
27462
+ function settingsFor(bridge2) {
27463
+ const active = () => {
27464
+ const running = bridge2();
27465
+ if (running === void 0) throw new Refuse("The harness is still starting.");
27466
+ return running;
27467
+ };
27468
+ return {
27469
+ models: (change) => active().modelChanged(change),
27470
+ context: (edit) => active().contextEdited(edit),
27471
+ contextWindows: {
27472
+ compact: (ask2) => active().windowCompact(ask2),
27473
+ change: (ask2) => active().windowChange(ask2)
27474
+ }
27475
+ };
27476
+ }
27477
+ function decisionOf(decision, command = false) {
26584
27478
  if (!decision.allowed) return "reject";
26585
- return decision.scope === "always" ? "always" : "once";
27479
+ return decision.scope === "always" && !command ? "always" : "once";
26586
27480
  }
26587
27481
  function questionOf(field, title2) {
26588
27482
  const prompt = (field.description ?? field.title ?? title2).slice(0, 500);
@@ -26668,6 +27562,12 @@ function spoken(words4) {
26668
27562
  return end2 === -1 ? words4 : words4.slice(end2 + THOUGHT_ENDS.length);
26669
27563
  }
26670
27564
  var THOUGHT_ENDS = "</think>";
27565
+ function titleFrom(text2) {
27566
+ const line = text2.trim().split("\n")[0] ?? "";
27567
+ if (line.length <= 48) return line;
27568
+ const cut2 = line.slice(0, 48);
27569
+ return `${cut2.slice(0, Math.max(cut2.lastIndexOf(" "), 24))}\u2026`;
27570
+ }
26671
27571
  function forkName(original) {
26672
27572
  return `${original.trim().replace(/ · fork( \d+)?$/, "")} \xB7 fork`;
26673
27573
  }
@@ -26732,12 +27632,12 @@ function lastLine(said) {
26732
27632
  }
26733
27633
 
26734
27634
  // src/keep.ts
26735
- import { homedir as homedir4 } from "node:os";
26736
- import { basename as basename3, join as join6, resolve as resolve2 } from "node:path";
27635
+ import { homedir as homedir5 } from "node:os";
27636
+ import { basename as basename3, join as join7, resolve as resolve2 } from "node:path";
26737
27637
  import { fileURLToPath as fileURLToPath2 } from "node:url";
26738
27638
  var flags = process.argv.slice(2);
26739
27639
  var words3 = flags.filter((word) => !word.startsWith("--"));
26740
- var whole = (word) => resolve2(word.startsWith("~/") ? join6(homedir4(), word.slice(2)) : word);
27640
+ var whole = (word) => resolve2(word.startsWith("~/") ? join7(homedir5(), word.slice(2)) : word);
26741
27641
  var here = fileURLToPath2(new URL("..", import.meta.url)).replace(/\/$/, "");
26742
27642
  var self = fileURLToPath2(import.meta.url);
26743
27643
  var run = self.endsWith(".js") ? [self] : ["--import", "tsx", "src/main.ts"];
@@ -26755,7 +27655,7 @@ function stopIfAsked(label2) {
26755
27655
  if (stopped2.state === "theirs") end(`${stopped2.path} wasn't written by this bridge: it was left as it is.`, 1);
26756
27656
  end(`Nothing called ${label2} was kept running here.`, 0);
26757
27657
  }
26758
- function keepIfAsked(label2, url2, names, log2, before = () => void 0) {
27658
+ function keepIfAsked(label2, url2, names, log2, before2 = () => void 0) {
26759
27659
  if (!flags.includes("--keep")) return;
26760
27660
  const node2 = keptNode();
26761
27661
  const kept = keepRunning({
@@ -26765,7 +27665,7 @@ function keepIfAsked(label2, url2, names, log2, before = () => void 0) {
26765
27665
  environment: keepEnvironment(url2, process.env, names, node2)
26766
27666
  });
26767
27667
  if (kept.state === "kept") {
26768
- before();
27668
+ before2();
26769
27669
  end(
26770
27670
  `Kept running as ${label2}: it starts at login, and again whenever it stops. What it says goes to ${kept.logFile}. \`${stopLine}\` stops it.`,
26771
27671
  0
@@ -26785,9 +27685,9 @@ function keepIfAsked(label2, url2, names, log2, before = () => void 0) {
26785
27685
  // src/server.ts
26786
27686
  import { spawn } from "node:child_process";
26787
27687
  import { randomBytes } from "node:crypto";
26788
- import { accessSync as accessSync2, constants as constants2, existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
27688
+ import { accessSync as accessSync2, constants as constants2, existsSync as existsSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
26789
27689
  import { createServer } from "node:net";
26790
- import { delimiter as delimiter2, join as join7 } from "node:path";
27690
+ import { delimiter as delimiter2, join as join8 } from "node:path";
26791
27691
  import { createInterface } from "node:readline";
26792
27692
  var ServerError = class extends Error {
26793
27693
  constructor(message, missing = false) {
@@ -26797,10 +27697,11 @@ var ServerError = class extends Error {
26797
27697
  missing;
26798
27698
  name = "ServerError";
26799
27699
  };
27700
+ var sleep = (ms) => new Promise((resolve5) => setTimeout(resolve5, ms));
26800
27701
  var onPath = (command, env) => command.includes("/") ? existsSync5(command) : (env["PATH"] ?? "").split(delimiter2).some((folder2) => {
26801
27702
  if (folder2 === "") return false;
26802
27703
  try {
26803
- accessSync2(join7(folder2, command), constants2.X_OK);
27704
+ accessSync2(join8(folder2, command), constants2.X_OK);
26804
27705
  return true;
26805
27706
  } catch {
26806
27707
  return false;
@@ -26817,48 +27718,66 @@ async function freePort() {
26817
27718
  });
26818
27719
  return await tryPort(4096) ?? await tryPort(0) ?? 4096;
26819
27720
  }
26820
- async function answers2(url2, password) {
27721
+ async function infoOf(url2, password) {
26821
27722
  try {
26822
27723
  const answer = await fetch(`${url2}/api/info`, {
26823
27724
  headers: { authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` },
26824
27725
  signal: AbortSignal.timeout(2e3)
26825
27726
  });
26826
- if (answer.ok) return "ok";
27727
+ if (answer.ok) return await answer.json().catch(() => ({})) ?? {};
26827
27728
  return answer.status === 401 ? "refused" : "none";
26828
27729
  } catch {
26829
27730
  return "none";
26830
27731
  }
26831
27732
  }
27733
+ async function answers2(url2, password) {
27734
+ const info = await infoOf(url2, password);
27735
+ return typeof info === "string" ? info : "ok";
27736
+ }
26832
27737
  function keptIn(path) {
26833
27738
  if (!existsSync5(path)) return void 0;
26834
27739
  try {
26835
- const kept = JSON.parse(readFileSync5(path, "utf8"));
27740
+ const kept = JSON.parse(readFileSync6(path, "utf8"));
26836
27741
  return typeof kept.port === "number" && typeof kept.password === "string" ? kept : void 0;
26837
27742
  } catch {
26838
27743
  return void 0;
26839
27744
  }
26840
27745
  }
27746
+ function alive(pid) {
27747
+ try {
27748
+ process.kill(pid, 0);
27749
+ return true;
27750
+ } catch (error62) {
27751
+ return error62.code === "EPERM";
27752
+ }
27753
+ }
27754
+ async function ended(pid, ms) {
27755
+ for (const signal of ["SIGTERM", "SIGKILL"]) {
27756
+ try {
27757
+ process.kill(pid, signal);
27758
+ } catch {
27759
+ return;
27760
+ }
27761
+ for (const by = Date.now() + (signal === "SIGTERM" ? ms : 2e3); alive(pid) && Date.now() < by; ) await sleep(50);
27762
+ if (!alive(pid)) return;
27763
+ }
27764
+ }
26841
27765
  async function ownServer(options) {
26842
27766
  const env = options.env ?? process.env;
26843
27767
  const command = options.command ?? "opencode";
26844
27768
  const log2 = options.log ?? (() => void 0);
26845
- const path = join7(options.folder, "server.json");
27769
+ const path = join8(options.folder, "server.json");
26846
27770
  let kept = keptIn(path);
27771
+ let before2 = false;
26847
27772
  if (kept !== void 0) {
26848
- const url3 = `http://127.0.0.1:${kept.port}`;
26849
- const now = await answers2(url3, kept.password);
26850
- if (now === "ok") {
26851
- log2(`OpenCode's server from before is still answering at ${url3}`);
26852
- return { url: url3, password: kept.password, stop: () => void 0 };
26853
- }
27773
+ const now = await answers2(`http://127.0.0.1:${kept.port}`, kept.password);
27774
+ before2 = now === "ok";
26854
27775
  if (now === "refused") kept = { port: await freePort(), password: kept.password };
26855
27776
  }
26856
- if (!onPath(command, env)) {
27777
+ if (!before2 && !onPath(command, env)) {
26857
27778
  throw new ServerError("OpenCode isn't installed on this machine: install it (opencode.ai), then run this again.", true);
26858
27779
  }
26859
27780
  kept ??= { port: await freePort(), password: randomBytes(24).toString("base64url") };
26860
- writeFileSync4(path, `${JSON.stringify(kept, null, 2)}
26861
- `, { mode: 384 });
26862
27781
  const { port, password } = kept;
26863
27782
  const url2 = `http://127.0.0.1:${port}`;
26864
27783
  let child;
@@ -26866,8 +27785,11 @@ async function ownServer(options) {
26866
27785
  let answered = false;
26867
27786
  let failed;
26868
27787
  let wait2 = 2e3;
27788
+ let again2;
26869
27789
  const said = [];
26870
27790
  const start = () => {
27791
+ writeFileSync4(path, `${JSON.stringify({ port, password }, null, 2)}
27792
+ `, { mode: 384 });
26871
27793
  const started = spawn(command, ["serve", "--hostname", "127.0.0.1", "--port", String(port)], {
26872
27794
  cwd: options.cwd,
26873
27795
  env: { ...env, OPENCODE_PASSWORD: password },
@@ -26889,9 +27811,10 @@ async function ownServer(options) {
26889
27811
  return;
26890
27812
  }
26891
27813
  log2(`OpenCode's server stopped (${signal ?? `exit ${code}`}): starting it again in ${wait2 / 1e3} s`);
26892
- setTimeout(() => {
26893
- if (!stopping) start();
26894
- }, wait2).unref();
27814
+ again2 = setTimeout(() => {
27815
+ if (!stopping && child === void 0) start();
27816
+ }, wait2);
27817
+ again2.unref();
26895
27818
  wait2 = Math.min(wait2 * 2, 6e4);
26896
27819
  });
26897
27820
  started.on("error", (error62) => {
@@ -26900,33 +27823,76 @@ async function ownServer(options) {
26900
27823
  });
26901
27824
  child = started;
26902
27825
  };
26903
- start();
26904
- const by = Date.now() + (options.waitMs ?? 3e4);
26905
- while (await answers2(url2, password) !== "ok") {
26906
- if (failed !== void 0 || Date.now() > by) {
26907
- stopping = true;
26908
- child?.kill("SIGTERM");
26909
- const last = said.slice(-3).join(" ").trim();
26910
- throw new ServerError(`OpenCode's server didn't start at ${url2}${last === "" ? "" : `: ${last}`}`);
27826
+ const up = async (anew) => {
27827
+ stopping = false;
27828
+ answered = false;
27829
+ failed = void 0;
27830
+ said.length = 0;
27831
+ start();
27832
+ const by = Date.now() + (options.waitMs ?? 3e4);
27833
+ while (await answers2(url2, password) !== "ok") {
27834
+ if (failed !== void 0 || Date.now() > by) {
27835
+ stopping = true;
27836
+ child?.kill("SIGTERM");
27837
+ const last = said.slice(-3).join(" ").trim();
27838
+ throw new ServerError(`OpenCode's server didn't start${anew ? " again" : ""} at ${url2}${last === "" ? "" : `: ${last}`}`);
27839
+ }
27840
+ await sleep(250);
27841
+ }
27842
+ answered = true;
27843
+ wait2 = 2e3;
27844
+ };
27845
+ const down = async () => {
27846
+ stopping = true;
27847
+ clearTimeout(again2);
27848
+ const stopMs = options.stopMs ?? 1e4;
27849
+ const pid = child?.pid;
27850
+ if (pid !== void 0) return ended(pid, stopMs);
27851
+ const info = await infoOf(url2, password);
27852
+ if (typeof info === "string") return;
27853
+ if (typeof info["pid"] !== "number" || info["pid"] <= 0 || info["pid"] === process.pid) {
27854
+ throw new ServerError(
27855
+ `OpenCode's server at ${url2} was running before this bridge and doesn't say which process it is: stop it on this machine, then restart again.`
27856
+ );
26911
27857
  }
26912
- await new Promise((resolve5) => setTimeout(resolve5, 250));
27858
+ await ended(info["pid"], stopMs);
27859
+ };
27860
+ if (before2) log2(`OpenCode's server from before is still answering at ${url2}`);
27861
+ else {
27862
+ await up(false);
27863
+ log2(`OpenCode's own server answering at ${url2}`);
26913
27864
  }
26914
- answered = true;
26915
- log2(`OpenCode's own server answering at ${url2}`);
27865
+ let restarting;
27866
+ const restart = async () => {
27867
+ if (!onPath(command, env)) {
27868
+ throw new ServerError("OpenCode isn't installed on this machine any more: install it (opencode.ai), then restart again.", true);
27869
+ }
27870
+ log2(`restarting OpenCode's server at ${url2}`);
27871
+ await down();
27872
+ await up(true);
27873
+ log2(`OpenCode's server is answering again at ${url2}`);
27874
+ };
26916
27875
  return {
26917
27876
  url: url2,
26918
27877
  password,
26919
27878
  stop: () => {
26920
27879
  stopping = true;
27880
+ clearTimeout(again2);
26921
27881
  child?.kill("SIGTERM");
27882
+ },
27883
+ restart: () => {
27884
+ restarting ??= restart().finally(() => {
27885
+ restarting = void 0;
27886
+ });
27887
+ return restarting;
26922
27888
  }
26923
27889
  };
26924
27890
  }
26925
27891
 
26926
27892
  // src/setup.ts
26927
- import { existsSync as existsSync6, readFileSync as readFileSync6 } from "node:fs";
26928
- import { homedir as homedir5 } from "node:os";
26929
- import { join as join8, resolve as resolve3 } from "node:path";
27893
+ import { existsSync as existsSync6, readFileSync as readFileSync7 } from "node:fs";
27894
+ import { homedir as homedir6 } from "node:os";
27895
+ import { join as join9, resolve as resolve3 } from "node:path";
26930
27896
  var SetupError = class extends Error {
26931
27897
  constructor(message, words4 = {}) {
26932
27898
  super(message);
@@ -26943,9 +27909,9 @@ var hostOf = (url2) => {
26943
27909
  }
26944
27910
  };
26945
27911
  var STILL_GOOD = "The link is still good: run the same command again once that's done.";
26946
- var home = (path) => path.startsWith("~/") ? join8(homedir5(), path.slice(2)) : path;
27912
+ var home = (path) => path.startsWith("~/") ? join9(homedir6(), path.slice(2)) : path;
26947
27913
  function readConfig(path, env = process.env) {
26948
- const raw = existsSync6(path) ? JSON.parse(readFileSync6(path, "utf8")) : {};
27914
+ const raw = existsSync6(path) ? JSON.parse(readFileSync7(path, "utf8")) : {};
26949
27915
  const passwordEnv = typeof raw["passwordEnv"] === "string" ? raw["passwordEnv"] : "OPENCODE_PASSWORD";
26950
27916
  const password = filledIn(env[passwordEnv]) ? env[passwordEnv] : void 0;
26951
27917
  const ownServer2 = password === void 0 && typeof raw["url"] !== "string";
@@ -27001,7 +27967,7 @@ async function checkOpenCode(config3, client) {
27001
27967
  }
27002
27968
 
27003
27969
  // src/main.ts
27004
- var home2 = (path) => path.startsWith("~/") ? join9(homedir6(), path.slice(2)) : path;
27970
+ var home2 = (path) => path.startsWith("~/") ? join10(homedir7(), path.slice(2)) : path;
27005
27971
  var link = process.env["CHIPS_JOIN_LINK"] || void 0;
27006
27972
  var LABEL = "chat.chips.opencode";
27007
27973
  stopIfAsked(LABEL);
@@ -27021,13 +27987,13 @@ try {
27021
27987
  } catch (error62) {
27022
27988
  config2 = stop(error62);
27023
27989
  }
27024
- var folder = dirname4(configPath);
27990
+ var folder = dirname5(configPath);
27025
27991
  mkdirSync5(folder, { recursive: true, mode: 448 });
27026
- var statePath = join9(folder, "sessions.json");
27992
+ var statePath = join10(folder, "sessions.json");
27027
27993
  var server;
27028
27994
  if (link !== void 0) reportStep("checking", { detail: "Looking for OpenCode" });
27029
27995
  if (config2.ownServer) {
27030
- const cwd = config2.directory ?? dirname4(config2.folders);
27996
+ const cwd = config2.directory ?? dirname5(config2.folders);
27031
27997
  mkdirSync5(cwd, { recursive: true });
27032
27998
  try {
27033
27999
  server = await ownServer({ folder, cwd, log });
@@ -27068,20 +28034,26 @@ var joined = await join3({
27068
28034
  url: process.env["CHIPS_URL"],
27069
28035
  name: config2.name,
27070
28036
  software: "chips-opencode 1.0.0",
27071
- wants: WANTS,
28037
+ wants: wantsFor(server !== void 0),
27072
28038
  onCode: (code) => log(`Allow this harness in Chips: ${code}`)
27073
28039
  }).catch(refused2);
27074
28040
  keepIfAsked(LABEL, joined.url, [config2.passwordEnv], log, () => server?.stop());
27075
- var state = existsSync7(statePath) ? JSON.parse(readFileSync7(statePath, "utf8")) : { sessions: {} };
28041
+ var state = existsSync7(statePath) ? JSON.parse(readFileSync8(statePath, "utf8")) : { sessions: {} };
28042
+ var bridge;
27076
28043
  var chips = await connect({
27077
28044
  url: joined.url,
27078
28045
  token: joined.token,
27079
28046
  client: { name: "chips-opencode", version: "1.0.0" },
27080
- store: fileStore(join9(folder, "chips-state.json"))
28047
+ store: fileStore(join10(folder, "chips-state.json")),
28048
+ // A person's Restart (Chips ADR 0094), heard only where the bridge runs OpenCode's server: one a
28049
+ // person runs themselves isn't the bridge's to restart, so this connection doesn't say it hears them.
28050
+ ...server === void 0 ? {} : { onRestart: () => bridge?.restart() },
28051
+ // What a person changes of an agent from its page (Chips ADR 0241): its model and its files.
28052
+ settings: settingsFor(() => bridge)
27081
28053
  });
27082
28054
  var git = await checkGit();
27083
28055
  if (!git.ok) log(`rooms' projects are off: ${git.why}`);
27084
- var bridge = new Bridge({
28056
+ bridge = new Bridge({
27085
28057
  chips,
27086
28058
  opencode,
27087
28059
  projects: git.ok,
@@ -27091,7 +28063,8 @@ var bridge = new Bridge({
27091
28063
  saveState: (next) => writeFileSync5(statePath, `${JSON.stringify(next, null, 2)}
27092
28064
  `, { mode: 384 }),
27093
28065
  log,
27094
- ...config2.directory === void 0 ? { folders: config2.folders } : {}
28066
+ restartServer: server?.restart,
28067
+ ...config2.directory === void 0 ? { folders: config2.folders } : { directory: config2.directory }
27095
28068
  });
27096
28069
  await bridge.start();
27097
28070
  log(`answering for ${config2.agents.map((agent) => agent.name).join(", ")} through OpenCode at ${address.url}`);