chalkbridge 1.0.0 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,100 @@
1
+ # chalkbridge
2
+
3
+ ## 1.0.3
4
+
5
+ **An upgrade now actually arrives, a bridge can be stopped, and it ends when you are
6
+ done with it.**
7
+
8
+ (1.0.2 was built and verified but never published — everything below shipped as 1.0.3.)
9
+
10
+ The bridge is spawned detached and outlives the session that started it — deliberately,
11
+ because writes have to succeed with no browser connected. But `ensureBridge` reused
12
+ whatever was already listening without ever checking its version, so `npx -y chalkbridge
13
+ mcp` would fetch a new package, find the **old** bridge still answering, and reuse it.
14
+ Every fix would ship and none would arrive, silently. If 1.0.1 seemed not to change
15
+ anything for you, that is why.
16
+
17
+ - **A bridge from another build is replaced.** It is asked to stop first, so its pending
18
+ `.excalidraw` write still reaches the disk.
19
+ - **`chalkbridge stop`** ends the bridge for the current repo, flushing anything unsaved.
20
+ Until now the only options were a reboot or hunting the pid.
21
+ - **`POST /api/shutdown`** is what both use — a request rather than a signal, because on
22
+ Windows a signal from another process terminates outright and the flush never happens.
23
+ - **A pid fallback for bridges built before this release.** 1.0.0 and 1.0.1 have no
24
+ shutdown endpoint, so the polite path is unavailable for exactly the bridges an upgrade
25
+ has to replace. Without it, upgrading from either would leave the new build unable to
26
+ start at all. Found by running the published 1.0.1 against a local build.
27
+
28
+ **And it ends when you are done with it.** Until now a bridge ran until the machine
29
+ restarted — one per repo, forever — which is not what anybody expects after closing a chat
30
+ and a tab. It now exits when nothing is watching *and* nothing is asking, for 90 seconds:
31
+
32
+ | | |
33
+ |---|---|
34
+ | chat closed, tab closed | exits |
35
+ | tab refreshed | reconnects in about a second, so it stays |
36
+ | chat closed, tab still open | stays — somebody is looking at the canvas |
37
+ | Claude drawing with no tab open | every tool call counts, so it stays |
38
+ | two chats on one repo | activity keeps it alive |
39
+
40
+ One rule, no session counting. `CHALK_IDLE_TIMEOUT` tunes it and `0` restores the old
41
+ behaviour.
42
+
43
+ **A tool call now survives its own bridge.** That is what makes the above invisible: if a
44
+ bridge has given up, the next call re-runs the bootstrap — spawning one and reopening the
45
+ canvas — instead of failing. Previously a bridge lost mid-session meant every call failed
46
+ until Claude Code was restarted.
47
+
48
+ After this release, updating really is just restarting Claude Code.
49
+
50
+ ## 1.0.1
51
+
52
+ **Arrows are drawn where they belong, the first time.**
53
+
54
+ Six bugs of the same family, all found by drawing one real diagram — 29 boxes, 34 arrows —
55
+ which cost roughly forty tool calls of workaround. Every one of them answered `applied`
56
+ and then did less than that, which is the most expensive shape a bug can have: the caller
57
+ spends its next calls re-reading to find out why.
58
+
59
+ - **`chalk_connect` now places the arrow.** The expander returns a stub at the origin with
60
+ `points: [[0.5, 0], [99.5, 0]]` — the bindings were correct and the geometry was a
61
+ placeholder — so every new arrow landed in the same corner and looked like a scribble.
62
+ - **`chalk_update_elements` redraws the arrows of a box it moved**, and brings the box's
63
+ caption with it. Only `chalk_arrange` used to do either, so the only way to repair a
64
+ diagram was to displace every element and align it back, one row at a time.
65
+ - **`chalk_frame` works more than once.** Upstream rebuilds a frame from its `children`,
66
+ which a frame that already exists does not have, so it threw — and *every second frame
67
+ failed*, whatever was in it.
68
+ - **`patch.role` restyles** instead of writing a junk `role` field onto the element.
69
+ Explicit properties still win over what the role implies.
70
+ - **`patch.label` renames the caption** instead of writing a junk `label` field, and says
71
+ so plainly when there is no caption to rename rather than silently doing nothing.
72
+ - **Deleting a container tombstones its caption**, instead of leaving a word floating on
73
+ the canvas that nothing owns.
74
+
75
+ `applied` and `deleted` now list only the ids you asked about. A caption that followed its
76
+ box is a consequence, not a result you should have to reason about.
77
+
78
+ No schema changes — every tool takes and returns exactly what it did in 1.0.0.
79
+
80
+ ## 1.0.0
81
+
82
+ First release.
83
+
84
+ A self-hosted Excalidraw canvas that Claude Code can read and draw on. The canvas opens in
85
+ your browser, you select on it and ask in the terminal, and the diagram is a plain
86
+ `.excalidraw` file in your repository.
87
+
88
+ - **Read** — `chalk_describe_scene` renders the canvas as a compact text form, 3.7× smaller
89
+ than the same structure as JSON. `chalk_get_selection` answers "what are these?"
90
+ including the edges that cross the selection boundary.
91
+ - **Draw** — boxes by `role` rather than by colour, arrows bound to their boxes, layered
92
+ graph layout, frames, groups, annotations. Writes succeed with no browser connected.
93
+ - **Illustrate** — a machine-wide icon catalog with FTS5 search; a hand-drawn set is
94
+ bundled and `chalk_install_pack("devicon")` adds technology logos in one call. Vendor
95
+ sets return instructions and are never downloaded for you.
96
+ - **Export** — PNG and SVG rendered by your own browser, and `.excalidraw` written where
97
+ you asked.
98
+ - **Undo** — labelled snapshots.
99
+
100
+ Node 22 or newer. No native modules, no container, nothing leaves the machine.
package/README.md CHANGED
@@ -53,10 +53,26 @@ reach loopback. Do not work around it by binding a wider address.
53
53
  ```bash
54
54
  chalkbridge health # what is running for this repository
55
55
  chalkbridge serve # start the bridge by hand
56
+ chalkbridge stop # end it, flushing anything unsaved
56
57
  chalkbridge icons list # what is in the machine-wide icon catalog
57
58
  chalkbridge icons install devicon
58
59
  ```
59
60
 
61
+ **The bridge is spawned detached and outlives your session** — deliberately, because
62
+ writes have to succeed with no browser connected. It ends when nothing is watching *and*
63
+ nothing is asking:
64
+
65
+ | | |
66
+ |---|---|
67
+ | chat closed, tab closed | exits after 90 seconds |
68
+ | tab refreshed | reconnects in about a second, so it stays |
69
+ | chat closed, tab still open | stays — you are still looking at the canvas |
70
+ | Claude drawing with no tab | every tool call counts, so it stays |
71
+
72
+ If it has already given up, the next tool call brings it back and reopens the canvas, so
73
+ you never have to think about it. `chalkbridge stop` ends one now; `CHALK_IDLE_TIMEOUT`
74
+ tunes the ninety seconds, and `0` keeps it up forever.
75
+
60
76
  `CHALK_NO_AUTOSTART=1` and `CHALK_NO_OPEN=1` turn off the two automatic behaviours;
61
77
  `CHALK_PORT` pins the port; `CHALK_DATA_DIR` moves the icon catalog.
62
78
 
@@ -1248,6 +1248,14 @@ var bridgeUnreachable = (port) => port === null ? err("BRIDGE_UNREACHABLE", "no
1248
1248
  hint: "the bridge may have exited \u2014 `chalkbridge health` reports what is running",
1249
1249
  detail: { port }
1250
1250
  });
1251
+ var bridgeVersionMismatch = (running, ours, port) => err(
1252
+ "BRIDGE_VERSION_MISMATCH",
1253
+ `a Chalkbridge ${running} bridge is running on 127.0.0.1:${port} and this is ${ours}`,
1254
+ {
1255
+ hint: "it did not stop when asked \u2014 run `chalkbridge stop` in this repo, or end that process, and try again",
1256
+ detail: { running, ours, port }
1257
+ }
1258
+ );
1251
1259
  var portUnavailable = (from, tried) => err("PORT_UNAVAILABLE", `no free port between ${from} and ${from + tried - 1}`, {
1252
1260
  hint: "something is occupying the whole range \u2014 set CHALK_PORT to pick a different one",
1253
1261
  detail: { from, tried }
@@ -1266,6 +1274,11 @@ var unknownElement = (id) => err("UNKNOWN_ELEMENT", `no element ${id} in this sc
1266
1274
  tool: "chalk_describe_scene",
1267
1275
  detail: { id }
1268
1276
  });
1277
+ var noBoundLabel = (id) => err("NO_BOUND_LABEL", `element ${id} has no caption to rename`, {
1278
+ hint: "draw it with a label in the first place, or put the text beside it with chalk_annotate",
1279
+ tool: "chalk_annotate",
1280
+ detail: { id }
1281
+ });
1269
1282
  var emptySelection = (tool) => err("EMPTY_SELECTION", "no elements named \u2014 there is nothing to act on", {
1270
1283
  hint: "pass the ids you mean, or call chalk_get_selection to find out what the user is pointing at",
1271
1284
  tool: "chalk_get_selection",
@@ -1453,7 +1466,8 @@ function createNodeSkeletonExpander() {
1453
1466
  if (!loaded.ok) return loaded;
1454
1467
  let raw;
1455
1468
  try {
1456
- raw = loaded.value([...context ?? [], ...skeletons], { regenerateIds: false });
1469
+ const convertible = (context ?? []).filter((element) => element.type !== "frame");
1470
+ raw = loaded.value([...convertible, ...skeletons], { regenerateIds: false });
1457
1471
  } catch (thrown) {
1458
1472
  return fail(expansionFailed(describeThrown(thrown)));
1459
1473
  }
@@ -1489,6 +1503,6 @@ var stable = (element) => {
1489
1503
  return rest;
1490
1504
  };
1491
1505
 
1492
- export { AnnotateInput, AnnotateOutput, ApiError, ArrangeOutput, ArrangeToolInput, BINDING_FIELDS, ClientMessage, ConnectOutput, ConnectToolInput, CreateElementsInput, CreateElementsOutput, DebugElementRequest, DeleteElementsInput, DeleteElementsOutput, DescribeSceneInput, DescribeSceneOutput, DetailLevel, EXCALIDRAW_FILE_TYPE, EXCALIDRAW_FILE_VERSION, ElementId, EmbedInput, EmbedOutput, ExcalidrawFile, ExportImageInput, ExportImageOutput, ExportSceneInput, ExportSceneOutput, FLAG_GLYPH, FileId, FocusInput, FocusOutput, FrameId, FrameInput, FrameOutput, GUIDE_TOPICS, GetElementsInput, GetElementsOutput, GetSelectionInput, GetSelectionOutput, GroupId, GroupInput, GroupOutput, GuideTopic, HOUSE, HealthResponse, IconId, IconItem, ImportSceneInput, ImportSceneOutput, Index, InstallPackInput, InstallPackOutput, KIND_ABBREVIATION, ListPacksInput, ListPacksOutput, ListScenesInput, ListScenesOutput, PROTOCOL_VERSION, PackKind, PackName, PackRecipe, PlaceIconInput, PlaceIconOutput, ROUNDNESS, RawElementsInput, RawElementsOutput, ReadMeInput, ReadMeOutput, RemovePackInput, RemovePackOutput, RestoreSnapshotInput, RestoreSnapshotOutput, SERVER_MANAGED_FIELDS, STALE_SELECTION_MS, Scene, SceneElement, SceneId, SceneSettingsInput, SceneSettingsOutput, ScreenshotInput, ScreenshotOutput, ScreenshotResponse, SearchIconsInput, SearchIconsOutput, SnapshotId, SnapshotInput, SnapshotOutput, UngroupInput, UngroupOutput, UpdateElementsInput, UpdateElementsOutput, assertNever, bridgeSpawnFailed, bridgeUnreachable, catalogUnavailable, createNodeSkeletonExpander, deepEqual, describeThrown, diskConflict, emptySelection, expansionFailed, exportFailed, exportTimedOut, fail, frameIdOf, iconIdOf, idCollision, isFail, isOk, noBrowser, noScenes, notAPack, notARepo, ok, packInstallFailed, partition, pathOutsideRepo, point, portUnavailable, refusedFieldMessage, sceneFileMissing, staleVersion, storeUnavailable, unknownElement, unknownIcon, unknownScene, unknownSnapshot, unreadableSceneFile };
1493
- //# sourceMappingURL=chunk-HFKCLTVU.js.map
1494
- //# sourceMappingURL=chunk-HFKCLTVU.js.map
1506
+ export { AnnotateInput, AnnotateOutput, ApiError, ArrangeOutput, ArrangeToolInput, BINDING_FIELDS, ClientMessage, ConnectOutput, ConnectToolInput, CreateElementsInput, CreateElementsOutput, DebugElementRequest, DeleteElementsInput, DeleteElementsOutput, DescribeSceneInput, DescribeSceneOutput, DetailLevel, EXCALIDRAW_FILE_TYPE, EXCALIDRAW_FILE_VERSION, ElementId, EmbedInput, EmbedOutput, ExcalidrawFile, ExportImageInput, ExportImageOutput, ExportSceneInput, ExportSceneOutput, FLAG_GLYPH, FileId, FocusInput, FocusOutput, FrameId, FrameInput, FrameOutput, GUIDE_TOPICS, GetElementsInput, GetElementsOutput, GetSelectionInput, GetSelectionOutput, GroupId, GroupInput, GroupOutput, GuideTopic, HOUSE, HealthResponse, IconId, IconItem, ImportSceneInput, ImportSceneOutput, Index, InstallPackInput, InstallPackOutput, KIND_ABBREVIATION, ListPacksInput, ListPacksOutput, ListScenesInput, ListScenesOutput, PROTOCOL_VERSION, PackKind, PackName, PackRecipe, PlaceIconInput, PlaceIconOutput, ROUNDNESS, RawElementsInput, RawElementsOutput, ReadMeInput, ReadMeOutput, RemovePackInput, RemovePackOutput, RestoreSnapshotInput, RestoreSnapshotOutput, SERVER_MANAGED_FIELDS, STALE_SELECTION_MS, Scene, SceneElement, SceneId, SceneSettingsInput, SceneSettingsOutput, ScreenshotInput, ScreenshotOutput, ScreenshotResponse, SearchIconsInput, SearchIconsOutput, SnapshotId, SnapshotInput, SnapshotOutput, UngroupInput, UngroupOutput, UpdateElementsInput, UpdateElementsOutput, assertNever, bridgeSpawnFailed, bridgeUnreachable, bridgeVersionMismatch, catalogUnavailable, createNodeSkeletonExpander, deepEqual, describeThrown, diskConflict, emptySelection, expansionFailed, exportFailed, exportTimedOut, fail, frameIdOf, iconIdOf, idCollision, isFail, isOk, noBoundLabel, noBrowser, noScenes, notAPack, notARepo, ok, packInstallFailed, partition, pathOutsideRepo, point, portUnavailable, refusedFieldMessage, sceneFileMissing, staleVersion, storeUnavailable, unknownElement, unknownIcon, unknownScene, unknownSnapshot, unreadableSceneFile };
1507
+ //# sourceMappingURL=chunk-VTFAXWQJ.js.map
1508
+ //# sourceMappingURL=chunk-VTFAXWQJ.js.map
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { SceneElement, PackName, PackKind, IconId, SceneId, Scene, SnapshotId, SERVER_MANAGED_FIELDS, BINDING_FIELDS, GUIDE_TOPICS, DetailLevel, createNodeSkeletonExpander, ok, fail, bridgeSpawnFailed, bridgeUnreachable, GuideTopic, storeUnavailable, describeThrown, unreadableSceneFile, pathOutsideRepo, packInstallFailed, sceneFileMissing, notAPack, PackRecipe, portUnavailable, DescribeSceneOutput, DescribeSceneInput, GetSelectionOutput, GetSelectionInput, GetElementsOutput, GetElementsInput, ListScenesOutput, ListScenesInput, SceneSettingsOutput, SceneSettingsInput, SearchIconsOutput, SearchIconsInput, ScreenshotOutput, ScreenshotInput, ReadMeOutput, ReadMeInput, PlaceIconOutput, PlaceIconInput, SnapshotOutput, SnapshotInput, RestoreSnapshotOutput, RestoreSnapshotInput, ExportSceneOutput, ExportSceneInput, ImportSceneOutput, ImportSceneInput, ExportImageOutput, ExportImageInput, InstallPackOutput, InstallPackInput, notARepo, ScreenshotResponse, RemovePackOutput, ListPacksOutput, RawElementsOutput, FocusOutput, EmbedOutput, AnnotateOutput, FrameOutput, UngroupOutput, GroupOutput, ArrangeOutput, DeleteElementsOutput, UpdateElementsOutput, ConnectOutput, CreateElementsOutput, HealthResponse, iconIdOf, unknownScene, unknownSnapshot, exportFailed, unknownIcon, HOUSE, FileId, ElementId, diskConflict, refusedFieldMessage, idCollision, expansionFailed, emptySelection, unknownElement, frameIdOf, GroupId, staleVersion, deepEqual, exportTimedOut, noBrowser, DebugElementRequest, CreateElementsInput, ConnectToolInput, UpdateElementsInput, DeleteElementsInput, ArrangeToolInput, GroupInput, UngroupInput, FrameInput, AnnotateInput, EmbedInput, FocusInput, RawElementsInput, ListPacksInput, RemovePackInput, Index, ROUNDNESS, STALE_SELECTION_MS, noScenes, ClientMessage, assertNever, PROTOCOL_VERSION, ApiError, EXCALIDRAW_FILE_VERSION, EXCALIDRAW_FILE_TYPE, FLAG_GLYPH, point, KIND_ABBREVIATION, catalogUnavailable, FrameId, IconItem, ExcalidrawFile } from './chunk-HFKCLTVU.js';
2
+ import { SceneElement, PackName, PackKind, IconId, SceneId, Scene, SnapshotId, SERVER_MANAGED_FIELDS, BINDING_FIELDS, GUIDE_TOPICS, DetailLevel, createNodeSkeletonExpander, ok, fail, bridgeSpawnFailed, bridgeVersionMismatch, bridgeUnreachable, GuideTopic, storeUnavailable, describeThrown, unreadableSceneFile, pathOutsideRepo, packInstallFailed, sceneFileMissing, notAPack, PackRecipe, portUnavailable, DescribeSceneOutput, DescribeSceneInput, GetSelectionOutput, GetSelectionInput, GetElementsOutput, GetElementsInput, ListScenesOutput, ListScenesInput, SceneSettingsOutput, SceneSettingsInput, SearchIconsOutput, SearchIconsInput, ScreenshotOutput, ScreenshotInput, ReadMeOutput, ReadMeInput, PlaceIconOutput, PlaceIconInput, SnapshotOutput, SnapshotInput, RestoreSnapshotOutput, RestoreSnapshotInput, ExportSceneOutput, ExportSceneInput, ImportSceneOutput, ImportSceneInput, ExportImageOutput, ExportImageInput, InstallPackOutput, InstallPackInput, notARepo, ScreenshotResponse, RemovePackOutput, ListPacksOutput, RawElementsOutput, FocusOutput, EmbedOutput, AnnotateOutput, FrameOutput, UngroupOutput, GroupOutput, ArrangeOutput, DeleteElementsOutput, UpdateElementsOutput, ConnectOutput, CreateElementsOutput, HealthResponse, iconIdOf, unknownScene, unknownSnapshot, exportFailed, unknownIcon, HOUSE, FileId, ElementId, diskConflict, refusedFieldMessage, idCollision, expansionFailed, emptySelection, unknownElement, frameIdOf, GroupId, staleVersion, noBoundLabel, deepEqual, exportTimedOut, noBrowser, DebugElementRequest, CreateElementsInput, ConnectToolInput, UpdateElementsInput, DeleteElementsInput, ArrangeToolInput, GroupInput, UngroupInput, FrameInput, AnnotateInput, EmbedInput, FocusInput, RawElementsInput, ListPacksInput, RemovePackInput, Index, ROUNDNESS, STALE_SELECTION_MS, noScenes, ClientMessage, assertNever, PROTOCOL_VERSION, ApiError, EXCALIDRAW_FILE_VERSION, EXCALIDRAW_FILE_TYPE, point, FLAG_GLYPH, KIND_ABBREVIATION, catalogUnavailable, FrameId, IconItem, ExcalidrawFile } from './chunk-VTFAXWQJ.js';
3
3
  import './chunk-7D4SUZUM.js';
4
4
  import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
5
5
  import { homedir } from 'node:os';
@@ -1046,9 +1046,11 @@ var KNOWN_CODES = /* @__PURE__ */ new Set([
1046
1046
  "BRIDGE_SPAWN_FAILED",
1047
1047
  "BRIDGE_UNREACHABLE",
1048
1048
  "PORT_UNAVAILABLE",
1049
+ "BRIDGE_VERSION_MISMATCH",
1049
1050
  "STALE_VERSION",
1050
1051
  "ID_COLLISION",
1051
1052
  "UNKNOWN_ELEMENT",
1053
+ "NO_BOUND_LABEL",
1052
1054
  "EMPTY_SELECTION",
1053
1055
  "DISK_CONFLICT",
1054
1056
  "UNREADABLE_SCENE_FILE",
@@ -1098,23 +1100,48 @@ function createBridgeClient() {
1098
1100
  return null;
1099
1101
  }
1100
1102
  },
1101
- connect(port) {
1102
- const base = `http://127.0.0.1:${port}`;
1103
- async function call(path, schema, init, timeoutMs = DEFAULT_API_TIMEOUT_MS) {
1104
- let response;
1103
+ async shutdown(port, timeoutMs = DEFAULT_HEALTH_TIMEOUT_MS) {
1104
+ try {
1105
+ await fetch(`http://127.0.0.1:${port}/api/shutdown`, {
1106
+ method: "POST",
1107
+ signal: AbortSignal.timeout(timeoutMs)
1108
+ });
1109
+ } catch {
1110
+ }
1111
+ const deadline = Date.now() + timeoutMs * 4;
1112
+ while (Date.now() < deadline) {
1113
+ if (await this.health(port, timeoutMs) === null) return true;
1114
+ await new Promise((resolve6) => setTimeout(resolve6, 100));
1115
+ }
1116
+ return false;
1117
+ },
1118
+ connect(port, reconnect) {
1119
+ let current = port;
1120
+ async function attempt(path, init, timeoutMs) {
1105
1121
  try {
1106
- response = await fetch(`${base}${path}`, {
1122
+ return await fetch(`http://127.0.0.1:${current}${path}`, {
1107
1123
  ...init,
1108
1124
  signal: AbortSignal.timeout(timeoutMs)
1109
1125
  });
1110
1126
  } catch {
1111
- return fail(bridgeUnreachable(port));
1127
+ return null;
1128
+ }
1129
+ }
1130
+ async function call(path, schema, init, timeoutMs = DEFAULT_API_TIMEOUT_MS) {
1131
+ let response = await attempt(path, init, timeoutMs);
1132
+ if (response === null && reconnect !== void 0) {
1133
+ const revived = await reconnect();
1134
+ if (revived !== null) {
1135
+ current = revived;
1136
+ response = await attempt(path, init, timeoutMs);
1137
+ }
1112
1138
  }
1139
+ if (response === null) return fail(bridgeUnreachable(current));
1113
1140
  const body = await response.json().catch(() => null);
1114
- if (!response.ok) return fail(toChalkError(body, port));
1141
+ if (!response.ok) return fail(toChalkError(body, current));
1115
1142
  const parsed = schema.safeParse(body);
1116
1143
  if (!parsed.success) {
1117
- return fail(bridgeUnreachable(port));
1144
+ return fail(bridgeUnreachable(current));
1118
1145
  }
1119
1146
  return ok(parsed.data);
1120
1147
  }
@@ -1725,6 +1752,24 @@ function createBridgeLauncher(options, deps) {
1725
1752
  await sleep(pollIntervalMs);
1726
1753
  }
1727
1754
  return fail(bridgeSpawnFailed(`it did not start listening within ${readyTimeoutMs}ms`));
1755
+ },
1756
+ async stop(pid) {
1757
+ try {
1758
+ process.kill(pid, "SIGTERM");
1759
+ } catch {
1760
+ return false;
1761
+ }
1762
+ const deadline = Date.now() + 5e3;
1763
+ while (Date.now() < deadline) {
1764
+ try {
1765
+ process.kill(pid, 0);
1766
+ } catch {
1767
+ return true;
1768
+ }
1769
+ await sleep(pollIntervalMs);
1770
+ }
1771
+ deps.logger.warn("a bridge did not exit after being signalled", { pid });
1772
+ return false;
1728
1773
  }
1729
1774
  };
1730
1775
  }
@@ -1733,6 +1778,48 @@ function createBridgeLauncher(options, deps) {
1733
1778
  function createClock() {
1734
1779
  return { now: () => Date.now() };
1735
1780
  }
1781
+
1782
+ // src/adapters/system/idle-watch.ts
1783
+ var intervalFor = (timeoutMs) => Math.max(1e3, Math.min(1e4, Math.floor(timeoutMs / 3)));
1784
+ function createIdleWatch(options, deps) {
1785
+ let lastActivity = deps.clock.now();
1786
+ let timer = null;
1787
+ let fired = false;
1788
+ return {
1789
+ touch() {
1790
+ lastActivity = deps.clock.now();
1791
+ },
1792
+ start({ isWatched, onIdle }) {
1793
+ const timeoutMs = options.timeoutMs;
1794
+ if (timeoutMs === null) {
1795
+ deps.logger.debug("idle shutdown is disabled", { reason: "CHALK_IDLE_TIMEOUT=0" });
1796
+ return;
1797
+ }
1798
+ if (timer !== null) return;
1799
+ const every = options.checkIntervalMs ?? intervalFor(timeoutMs);
1800
+ timer = setInterval(() => {
1801
+ if (fired) return;
1802
+ if (isWatched()) {
1803
+ lastActivity = deps.clock.now();
1804
+ return;
1805
+ }
1806
+ if (deps.clock.now() - lastActivity < timeoutMs) return;
1807
+ fired = true;
1808
+ deps.logger.info("idle \u2014 nothing watching and nothing asking", {
1809
+ idleMs: deps.clock.now() - lastActivity
1810
+ });
1811
+ onIdle();
1812
+ }, every);
1813
+ timer.unref?.();
1814
+ deps.logger.debug("watching for idle", { timeoutMs, every });
1815
+ },
1816
+ stop() {
1817
+ if (timer === null) return;
1818
+ clearInterval(timer);
1819
+ timer = null;
1820
+ }
1821
+ };
1822
+ }
1736
1823
  var ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
1737
1824
  var shortId = (length) => {
1738
1825
  let out = "";
@@ -2426,7 +2513,20 @@ function ensureBridge(deps) {
2426
2513
  const repoRoot = await deps.projectDir.findRepoRoot(options.cwd);
2427
2514
  if (!repoRoot.ok) return repoRoot;
2428
2515
  const existingPort = await deps.projectDir.readPort(repoRoot.value);
2429
- const existing = existingPort === null ? null : await deps.client.health(existingPort);
2516
+ let existing = existingPort === null ? null : await deps.client.health(existingPort);
2517
+ if (existing !== null && existingPort !== null && existing.version !== deps.version) {
2518
+ deps.logger.info("replacing a bridge from another build", {
2519
+ running: existing.version,
2520
+ ours: deps.version,
2521
+ port: existingPort
2522
+ });
2523
+ const stopped = await deps.client.shutdown(existingPort) || await deps.launcher.stop(existing.pid);
2524
+ if (!stopped) {
2525
+ return fail(bridgeVersionMismatch(existing.version, deps.version, existingPort));
2526
+ }
2527
+ await deps.projectDir.clearPort(repoRoot.value);
2528
+ existing = null;
2529
+ }
2430
2530
  let port = existing === null ? null : existingPort;
2431
2531
  let health = existing;
2432
2532
  let spawned = false;
@@ -3237,8 +3337,42 @@ function edgePoint(from, to, gap) {
3237
3337
  const t = scale + push;
3238
3338
  return [cx + dx * t, cy + dy * t];
3239
3339
  }
3240
- function rebindArrows(scene, moved) {
3340
+ function redrawOne(current, labels, arrow) {
3341
+ if (arrow.type !== "arrow") return [];
3342
+ const fromId = startOf(arrow);
3343
+ const toId = endOf(arrow);
3344
+ if (fromId === null || toId === null) return [];
3345
+ const from = current.get(fromId);
3346
+ const to = current.get(toId);
3347
+ if (from === void 0 || to === void 0) return [];
3348
+ const startGap = arrow.startBinding?.gap ?? 4;
3349
+ const endGap = arrow.endBinding?.gap ?? 4;
3350
+ const [sx, sy] = edgePoint(boxOf(from), boxOf(to), startGap);
3351
+ const [ex, ey] = edgePoint(boxOf(to), boxOf(from), endGap);
3352
+ const points = [point(0, 0), point(ex - sx, ey - sy)];
3353
+ const redrawn = {
3354
+ ...arrow,
3355
+ x: sx,
3356
+ y: sy,
3357
+ width: Math.abs(ex - sx),
3358
+ height: Math.abs(ey - sy),
3359
+ points,
3360
+ lastCommittedPoint: null
3361
+ };
3362
+ const label = labels.get(arrow.id);
3363
+ if (label === void 0) return [redrawn];
3364
+ return [
3365
+ redrawn,
3366
+ {
3367
+ ...label,
3368
+ x: (sx + ex) / 2 - label.width / 2,
3369
+ y: (sy + ey) / 2 - label.height / 2
3370
+ }
3371
+ ];
3372
+ }
3373
+ function redrawArrowsTouching(scene, moved) {
3241
3374
  const current = new Map(scene.map((element) => [element.id, moved.get(element.id) ?? element]));
3375
+ const labels = labelsByContainer([...current.values()]);
3242
3376
  const redrawn = [];
3243
3377
  for (const element of current.values()) {
3244
3378
  if (element.type !== "arrow") continue;
@@ -3246,26 +3380,16 @@ function rebindArrows(scene, moved) {
3246
3380
  const toId = endOf(element);
3247
3381
  if (fromId === null || toId === null) continue;
3248
3382
  if (!moved.has(fromId) && !moved.has(toId)) continue;
3249
- const from = current.get(fromId);
3250
- const to = current.get(toId);
3251
- if (from === void 0 || to === void 0) continue;
3252
- const startGap = element.startBinding?.gap ?? 4;
3253
- const endGap = element.endBinding?.gap ?? 4;
3254
- const [sx, sy] = edgePoint(boxOf(from), boxOf(to), startGap);
3255
- const [ex, ey] = edgePoint(boxOf(to), boxOf(from), endGap);
3256
- const points = [point(0, 0), point(ex - sx, ey - sy)];
3257
- redrawn.push({
3258
- ...element,
3259
- x: sx,
3260
- y: sy,
3261
- width: Math.abs(ex - sx),
3262
- height: Math.abs(ey - sy),
3263
- points,
3264
- lastCommittedPoint: null
3265
- });
3383
+ redrawn.push(...redrawOne(current, labels, element));
3266
3384
  }
3267
3385
  return redrawn;
3268
3386
  }
3387
+ function redrawArrows(scene, ids) {
3388
+ const current = new Map(scene.map((element) => [element.id, element]));
3389
+ const labels = labelsByContainer(scene);
3390
+ const wanted = new Set(ids);
3391
+ return [...current.values()].filter((element) => wanted.has(element.id)).flatMap((element) => redrawOne(current, labels, element));
3392
+ }
3269
3393
  function translate(scene, deltas) {
3270
3394
  const labels = labelsByContainer(scene);
3271
3395
  const moved = /* @__PURE__ */ new Map();
@@ -3280,7 +3404,7 @@ function translate(scene, deltas) {
3280
3404
  moved.set(label.id, { ...label, x: label.x + dx, y: label.y + dy });
3281
3405
  }
3282
3406
  }
3283
- return [...moved.values(), ...rebindArrows(scene, moved)];
3407
+ return [...moved.values(), ...redrawArrowsTouching(scene, moved)];
3284
3408
  }
3285
3409
  var alignments = {
3286
3410
  "align-left": (box, bounds) => [bounds.x - box.x, 0],
@@ -3576,7 +3700,15 @@ function connectElements(deps) {
3576
3700
  };
3577
3701
  const created = await deps.write.create(scene.value, [skeleton], true);
3578
3702
  if (!created.ok) return created;
3579
- const committed = deps.write.append(scene.value, created.value);
3703
+ const placed = redrawArrows(
3704
+ [...scene.value.elements, ...created.value.created, ...created.value.changed],
3705
+ [skeleton.id]
3706
+ );
3707
+ const geometry = new Map(placed.map((element) => [element.id, element]));
3708
+ const committed = deps.write.append(scene.value, {
3709
+ created: created.value.created.map((element) => geometry.get(element.id) ?? element),
3710
+ changed: created.value.changed
3711
+ });
3580
3712
  if (!committed.ok) return committed;
3581
3713
  return ok({ ...committed.value, arrowId: skeleton.id });
3582
3714
  };
@@ -4195,6 +4327,13 @@ function rawElements(deps) {
4195
4327
 
4196
4328
  // src/app/usecases/write/update-elements.ts
4197
4329
  var missing = (id) => toRejection(id, unknownElement(id));
4330
+ var onlyRequested = (landed, requested) => {
4331
+ const asked = new Set(requested);
4332
+ return landed.filter((id) => asked.has(id));
4333
+ };
4334
+ var captionOf = (scene, container) => scene.find(
4335
+ (element) => element.type === "text" && element.containerId === container && !element.isDeleted
4336
+ );
4198
4337
  function updateElements(deps) {
4199
4338
  return (input) => {
4200
4339
  const scene = deps.write.resolve(input.sceneId);
@@ -4203,25 +4342,73 @@ function updateElements(deps) {
4203
4342
  const changed = [];
4204
4343
  const rejected = [];
4205
4344
  const expectations = [];
4345
+ const moved = /* @__PURE__ */ new Map();
4346
+ const knockOn = [];
4206
4347
  for (const update of input.updates) {
4207
4348
  const existing = byId.get(update.id);
4208
4349
  if (existing === void 0 || existing.isDeleted) {
4209
4350
  rejected.push(missing(update.id));
4210
4351
  continue;
4211
4352
  }
4353
+ const { role, label, ...rest } = update.patch;
4212
4354
  const patch = Object.fromEntries(
4213
- Object.entries(update.patch).filter(([, value]) => value !== void 0)
4355
+ Object.entries(rest).filter(([, value]) => value !== void 0)
4214
4356
  );
4215
- changed.push({ ...existing, ...patch });
4357
+ if (label !== void 0) {
4358
+ const caption = captionOf(scene.value.elements, update.id);
4359
+ if (caption === void 0) {
4360
+ rejected.push(toRejection(update.id, noBoundLabel(update.id)));
4361
+ continue;
4362
+ }
4363
+ knockOn.push({ ...caption, text: label, originalText: label });
4364
+ }
4365
+ const next = {
4366
+ ...existing,
4367
+ ...role === void 0 ? {} : styleForRole(role),
4368
+ ...patch
4369
+ };
4370
+ changed.push(next);
4216
4371
  expectations.push({ id: update.id, expectedVersion: update.expectedVersion });
4372
+ const dx = next.x - existing.x;
4373
+ const dy = next.y - existing.y;
4374
+ if (dx !== 0 || dy !== 0) moved.set(update.id, next);
4375
+ else if (next.width !== existing.width || next.height !== existing.height) {
4376
+ moved.set(update.id, next);
4377
+ }
4217
4378
  }
4218
- const applied = deps.write.apply(scene.value, changed, { expectations });
4379
+ if (moved.size > 0) {
4380
+ for (const [id, next] of moved) {
4381
+ const before = byId.get(id);
4382
+ const caption = captionOf(scene.value.elements, id);
4383
+ if (before === void 0 || caption === void 0) continue;
4384
+ knockOn.push({
4385
+ ...caption,
4386
+ x: caption.x + (next.x - before.x),
4387
+ y: caption.y + (next.y - before.y)
4388
+ });
4389
+ }
4390
+ knockOn.push(...redrawArrowsTouching(scene.value.elements, moved));
4391
+ }
4392
+ for (const element of knockOn) {
4393
+ const before = byId.get(element.id);
4394
+ if (before !== void 0) {
4395
+ expectations.push({ id: element.id, expectedVersion: before.version });
4396
+ }
4397
+ }
4398
+ const applied = deps.write.apply(scene.value, [...changed, ...knockOn], { expectations });
4219
4399
  if (!applied.ok) return applied;
4220
4400
  return ok({
4221
4401
  sceneId: applied.value.sceneId,
4222
4402
  sceneVersion: applied.value.sceneVersion,
4223
4403
  broadcastTo: applied.value.broadcastTo,
4224
- applied: applied.value.applied,
4404
+ // What the caller asked for, not everything that moved. A caption that followed its
4405
+ // box and an arrow that was redrawn are consequences; listing them would turn this
4406
+ // into "every element that changed" and leave the caller working out which of them
4407
+ // it had actually requested.
4408
+ applied: onlyRequested(
4409
+ applied.value.applied,
4410
+ input.updates.map((update) => update.id)
4411
+ ),
4225
4412
  rejected: [...rejected, ...applied.value.rejected]
4226
4413
  });
4227
4414
  };
@@ -4255,6 +4442,11 @@ function deleteElements(deps) {
4255
4442
  id: deletion.id,
4256
4443
  expectedVersion: deletion.expectedVersion ?? existing.version
4257
4444
  });
4445
+ const caption = captionOf(scene.value.elements, deletion.id);
4446
+ if (caption !== void 0) {
4447
+ changed.push({ ...caption, isDeleted: true });
4448
+ expectations.push({ id: caption.id, expectedVersion: caption.version });
4449
+ }
4258
4450
  }
4259
4451
  const applied = deps.write.apply(scene.value, changed, { expectations });
4260
4452
  if (!applied.ok) return applied;
@@ -4262,7 +4454,10 @@ function deleteElements(deps) {
4262
4454
  sceneId: applied.value.sceneId,
4263
4455
  sceneVersion: applied.value.sceneVersion,
4264
4456
  broadcastTo: applied.value.broadcastTo,
4265
- deleted: applied.value.applied,
4457
+ deleted: onlyRequested(
4458
+ applied.value.applied,
4459
+ input.deletions.map((deletion) => deletion.id)
4460
+ ),
4266
4461
  rejected: [...rejected, ...applied.value.rejected]
4267
4462
  });
4268
4463
  };
@@ -4275,6 +4470,7 @@ function printLine(line) {
4275
4470
  }
4276
4471
  var DEFAULT_PORT = 7180;
4277
4472
  var PORT_SCAN_RANGE = 32;
4473
+ var DEFAULT_IDLE_SECONDS = 90;
4278
4474
  var truthy = (value) => value !== void 0 && value !== "" && value !== "0" && value.toLowerCase() !== "false";
4279
4475
  var EnvSchema = z.object({
4280
4476
  CHALK_PORT: z.coerce.number().int().min(1).max(65535).optional(),
@@ -4287,12 +4483,18 @@ var EnvSchema = z.object({
4287
4483
  * icon pack into the developer real data directory would be a test that changed the
4288
4484
  * machine it ran on.
4289
4485
  */
4290
- CHALK_DATA_DIR: z.string().min(1).optional()
4486
+ CHALK_DATA_DIR: z.string().min(1).optional(),
4487
+ /**
4488
+ * Seconds a bridge may sit with no browser client and no tool call before it exits.
4489
+ * `0` keeps it up forever, which is what every version before v1.0.2 did.
4490
+ */
4491
+ CHALK_IDLE_TIMEOUT: z.coerce.number().int().min(0).max(86400).optional()
4291
4492
  });
4292
4493
  function readEnv(source) {
4293
4494
  const parsed = EnvSchema.safeParse(source);
4294
4495
  const env = parsed.success ? parsed.data : {};
4295
4496
  const explicit2 = env.CHALK_PORT ?? null;
4497
+ const idleSeconds = env.CHALK_IDLE_TIMEOUT ?? DEFAULT_IDLE_SECONDS;
4296
4498
  return {
4297
4499
  listenFrom: explicit2 ?? DEFAULT_PORT,
4298
4500
  listenTries: explicit2 === null ? PORT_SCAN_RANGE : 1,
@@ -4300,12 +4502,13 @@ function readEnv(source) {
4300
4502
  autoStart: !truthy(env.CHALK_NO_AUTOSTART),
4301
4503
  autoOpen: !truthy(env.CHALK_NO_OPEN),
4302
4504
  dataDir: env.CHALK_DATA_DIR ?? null,
4505
+ idleTimeoutMs: idleSeconds === 0 ? null : idleSeconds * 1e3,
4303
4506
  logLevel: env.CHALK_LOG_LEVEL ?? "info"
4304
4507
  };
4305
4508
  }
4306
4509
 
4307
4510
  // src/composition/container.ts
4308
- var PACKAGE_VERSION = "1.0.0";
4511
+ var PACKAGE_VERSION = "1.0.3";
4309
4512
  function readRuntime() {
4310
4513
  return {
4311
4514
  env: process.env,
@@ -4369,6 +4572,7 @@ function buildCommandContext(runtime = readRuntime()) {
4369
4572
  if (!opened.ok) return opened;
4370
4573
  const store = opened.value;
4371
4574
  const broadcaster = createBroadcaster(idGen, logger);
4575
+ const idleWatch = createIdleWatch({ timeoutMs: config.idleTimeoutMs }, { clock, logger });
4372
4576
  const presenceTracker = createPresenceTracker();
4373
4577
  const expander = createNodeSkeletonExpander();
4374
4578
  const repoWriter = createRepoWriter({ repoRoot }, { store, logger });
@@ -4376,6 +4580,7 @@ function buildCommandContext(runtime = readRuntime()) {
4376
4580
  const diskWatcher = createDiskWatcher({ logger });
4377
4581
  const commit = commitScene({ store, repoWriter });
4378
4582
  const repoFiles = createRepoFiles({ repoRoot });
4583
+ let onShutdown = null;
4379
4584
  const exportBroker = createExportBroker({}, { broadcaster, idGen, logger });
4380
4585
  const globalPaths = globalPathsOf(runtime, config);
4381
4586
  let catalog = null;
@@ -4539,6 +4744,10 @@ function buildCommandContext(runtime = readRuntime()) {
4539
4744
  // logic in `app/`, and a lifecycle command has no more business holding a store
4540
4745
  // than a route does.
4541
4746
  sceneFilePaths: () => store.list().map((scene) => scene.repoPath).filter((path) => path !== null).map((path) => resolve(repoRoot, path)),
4747
+ idleWatch,
4748
+ onShutdownRequest: (handler) => {
4749
+ onShutdown = handler;
4750
+ },
4542
4751
  close: () => {
4543
4752
  repoWriter.cancel();
4544
4753
  exportBroker.abandon();
@@ -4579,6 +4788,7 @@ function buildCommandContext(runtime = readRuntime()) {
4579
4788
  importScene: usecases.importScene,
4580
4789
  broadcaster,
4581
4790
  identity,
4791
+ requestShutdown: () => onShutdown?.(),
4582
4792
  logger
4583
4793
  },
4584
4794
  ws: {
@@ -4602,24 +4812,42 @@ function buildCommandContext(runtime = readRuntime()) {
4602
4812
  { cliEntry: runtime.entryPath, execPath: runtime.execPath, env: runtime.env },
4603
4813
  { projectDir, client, logger }
4604
4814
  );
4815
+ const bootstrap = ensureBridge({
4816
+ // Compared against whatever is already listening: an upgrade that reused an
4817
+ // older detached bridge would ship every fix and deliver none of them.
4818
+ version: runtime.version,
4819
+ projectDir,
4820
+ client,
4821
+ launcher,
4822
+ opener: createBrowserOpener(logger),
4823
+ logger
4824
+ });
4605
4825
  return {
4606
4826
  logger,
4607
4827
  projectDir,
4608
4828
  client,
4829
+ launcher,
4609
4830
  // A7 — the tool handlers never see a port, a URL, or a `fetch`. They see the
4610
4831
  // bridge's read surface and a shelf of markdown, and nothing else exists to
4611
4832
  // tempt them into caching a scene (invariant 1).
4833
+ //
4834
+ // The `reconnect` argument is what makes a session survive its own bridge. One
4835
+ // now exits when nothing is watching and nothing is asking, so "the port this
4836
+ // session started with" stops being true the moment the user closes the tab and
4837
+ // walks away. Re-running the bootstrap puts a bridge *and a tab* back, which is
4838
+ // what the session's first call did, so a tool call after a break just works.
4612
4839
  createToolDeps: (port) => ({
4613
- bridge: client.connect(port),
4840
+ bridge: client.connect(port, async () => {
4841
+ const revived = await bootstrap({
4842
+ cwd: runtime.cwd,
4843
+ autoStart: config.autoStart,
4844
+ autoOpen: config.autoOpen
4845
+ });
4846
+ return revived.ok ? revived.value.port : null;
4847
+ }),
4614
4848
  guides: createGuideLibrary({ dir: resolveGuidesDir() }, { logger })
4615
4849
  }),
4616
- ensureBridge: ensureBridge({
4617
- projectDir,
4618
- client,
4619
- launcher,
4620
- opener: createBrowserOpener(logger),
4621
- logger
4622
- })
4850
+ ensureBridge: bootstrap
4623
4851
  };
4624
4852
  }
4625
4853
  };
@@ -5606,6 +5834,8 @@ var STATUS = {
5606
5834
  BRIDGE_SPAWN_FAILED: 500,
5607
5835
  BRIDGE_UNREACHABLE: 503,
5608
5836
  PORT_UNAVAILABLE: 500,
5837
+ // 409: something is there, it is simply not the build this process belongs to.
5838
+ BRIDGE_VERSION_MISMATCH: 409,
5609
5839
  // 409 for both: a stale version and a file that changed underneath us are the same
5610
5840
  // shape of problem — the caller's view of the world is out of date, and re-reading is
5611
5841
  // the way forward.
@@ -5613,6 +5843,8 @@ var STATUS = {
5613
5843
  ID_COLLISION: 409,
5614
5844
  // 404, not 409: the id is not there to conflict with.
5615
5845
  UNKNOWN_ELEMENT: 404,
5846
+ // 422: the id is fine and the request is well-formed, there is simply nothing to rename.
5847
+ NO_BOUND_LABEL: 422,
5616
5848
  EMPTY_SELECTION: 400,
5617
5849
  DISK_CONFLICT: 409,
5618
5850
  UNREADABLE_SCENE_FILE: 422,
@@ -5862,6 +6094,20 @@ var sessionRoutes = [
5862
6094
  })
5863
6095
  ];
5864
6096
 
6097
+ // src/interfaces/http/routes/shutdown.ts
6098
+ var shutdownRoute = {
6099
+ name: "shutdown",
6100
+ register(app, deps) {
6101
+ app.post("/api/shutdown", async (_request, reply) => {
6102
+ const identity = deps.identity();
6103
+ deps.logger.info("shutdown requested over the API", { pid: identity.pid });
6104
+ await reply.send({ stopping: true, pid: identity.pid, version: identity.version });
6105
+ setTimeout(() => deps.requestShutdown(), 0);
6106
+ return reply;
6107
+ });
6108
+ }
6109
+ };
6110
+
5865
6111
  // src/interfaces/http/routes/write.ts
5866
6112
  var writeRoutes = [
5867
6113
  postRoute({
@@ -5947,6 +6193,7 @@ var ROUTES = [
5947
6193
  elementsRoute,
5948
6194
  settingsRoute,
5949
6195
  debugElementRoute,
6196
+ shutdownRoute,
5950
6197
  ...writeRoutes,
5951
6198
  ...sessionRoutes
5952
6199
  ];
@@ -5960,6 +6207,9 @@ async function createHttpServer(deps) {
5960
6207
  bodyLimit: 32 * 1024 * 1024
5961
6208
  });
5962
6209
  await app.register(websocket);
6210
+ app.addHook("onRequest", async () => {
6211
+ deps.touch();
6212
+ });
5963
6213
  for (const route of ROUTES) {
5964
6214
  route.register(app, deps.routes);
5965
6215
  deps.logger.debug("registered route", { route: route.name });
@@ -5969,6 +6219,7 @@ async function createHttpServer(deps) {
5969
6219
  const send = (message) => {
5970
6220
  socket.send(JSON.stringify(message));
5971
6221
  };
6222
+ deps.touch();
5972
6223
  const handle = deps.broadcaster.register(send);
5973
6224
  const session = { handle, send };
5974
6225
  socket.on("message", (data) => {
@@ -6049,7 +6300,8 @@ ${built.error.hint ?? ""}
6049
6300
  ws: services.ws,
6050
6301
  broadcaster: services.broadcaster,
6051
6302
  logger: services.logger,
6052
- uiDistPath: services.uiDistPath
6303
+ uiDistPath: services.uiDistPath,
6304
+ touch: () => services.idleWatch.touch()
6053
6305
  });
6054
6306
  const listening = await listenOnFirstFreePort(app, {
6055
6307
  host: "127.0.0.1",
@@ -6076,6 +6328,7 @@ ${built.error.hint ?? ""}
6076
6328
  await new Promise((resolve6) => {
6077
6329
  const shutdown = async (signal) => {
6078
6330
  services.logger.info("shutting down", { signal });
6331
+ services.idleWatch.stop();
6079
6332
  await services.diskWatcher.stop();
6080
6333
  const flushed = await services.repoWriter.flushAll();
6081
6334
  if (flushed.length > 0) {
@@ -6086,18 +6339,75 @@ ${built.error.hint ?? ""}
6086
6339
  services.close();
6087
6340
  resolve6();
6088
6341
  };
6342
+ services.idleWatch.start({
6343
+ isWatched: () => services.broadcaster.clientCount() > 0,
6344
+ onIdle: () => void shutdown("idle")
6345
+ });
6089
6346
  process.once("SIGINT", () => void shutdown("SIGINT"));
6090
6347
  process.once("SIGTERM", () => void shutdown("SIGTERM"));
6348
+ services.onShutdownRequest(() => void shutdown("api"));
6091
6349
  });
6092
6350
  return 0;
6093
6351
  }
6094
6352
  };
6095
6353
 
6354
+ // src/interfaces/cli/commands/stop.ts
6355
+ var stopCommand = {
6356
+ name: "stop",
6357
+ summary: "stop the bridge running for this repo, flushing anything unsaved",
6358
+ detail: [
6359
+ "Ends the bridge for the repository containing the working directory.",
6360
+ "",
6361
+ "The bridge flushes every pending .excalidraw write, stops watching the repo, and",
6362
+ "clears its port file on the way out \u2014 so nothing drawn in the last two seconds is",
6363
+ "lost. Nothing is started; the next Claude Code session starts a fresh one."
6364
+ ].join("\n"),
6365
+ async run(context) {
6366
+ const services = context.createClientServices({ prefix: "[stop]" });
6367
+ const repoRoot = await services.projectDir.findRepoRoot(context.runtime.cwd);
6368
+ if (!repoRoot.ok) {
6369
+ context.print(repoRoot.error.message);
6370
+ if (repoRoot.error.hint !== void 0) context.print(` ${repoRoot.error.hint}`);
6371
+ return 1;
6372
+ }
6373
+ const port = await services.projectDir.readPort(repoRoot.value);
6374
+ if (port === null) {
6375
+ context.print(`no bridge is running for ${repoRoot.value}`);
6376
+ return 0;
6377
+ }
6378
+ const health = await services.client.health(port);
6379
+ if (health === null) {
6380
+ await services.projectDir.clearPort(repoRoot.value);
6381
+ context.print(`nothing was listening on 127.0.0.1:${port} \u2014 cleared the stale port file`);
6382
+ return 0;
6383
+ }
6384
+ context.print(
6385
+ `stopping chalkbridge ${health.version} on 127.0.0.1:${port} (pid ${health.pid})`
6386
+ );
6387
+ if (await services.client.shutdown(port)) {
6388
+ context.print(" stopped \u2014 pending scene writes were flushed");
6389
+ return 0;
6390
+ }
6391
+ context.print(" it has no shutdown endpoint (built before v1.0.2) \u2014 ending it by pid");
6392
+ if (await services.launcher.stop(health.pid)) {
6393
+ await services.projectDir.clearPort(repoRoot.value);
6394
+ context.print(` stopped pid ${health.pid}`);
6395
+ return 0;
6396
+ }
6397
+ context.print(" it would not stop \u2014 end it yourself:");
6398
+ context.print(
6399
+ context.runtime.platform === "win32" ? ` taskkill /PID ${health.pid} /F` : ` kill ${health.pid}`
6400
+ );
6401
+ return 1;
6402
+ }
6403
+ };
6404
+
6096
6405
  // src/interfaces/cli/registry.ts
6097
6406
  var COMMANDS = [
6098
6407
  serveCommand,
6099
6408
  mcpCommand,
6100
6409
  healthCommand,
6410
+ stopCommand,
6101
6411
  iconsCommand
6102
6412
  ];
6103
6413
 
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { createNodeSkeletonExpander, fail, isFail, isOk, ok, partition } from './chunk-HFKCLTVU.js';
1
+ export { createNodeSkeletonExpander, fail, isFail, isOk, ok, partition } from './chunk-VTFAXWQJ.js';
2
2
  import './chunk-7D4SUZUM.js';
3
3
  //# sourceMappingURL=index.js.map
4
4
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chalkbridge",
3
- "version": "1.0.0",
3
+ "version": "1.0.3",
4
4
  "type": "module",
5
5
  "description": "Self-hosted Excalidraw canvas for Claude Code to draw and reason on architecture diagrams.",
6
6
  "license": "MIT",
@@ -22,6 +22,7 @@
22
22
  "ui-dist",
23
23
  "guides",
24
24
  "packs",
25
+ "CHANGELOG.md",
25
26
  "THIRD-PARTY-NOTICES.md"
26
27
  ],
27
28
  "bin": {