chalkbridge 1.0.1 → 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 CHANGED
@@ -1,5 +1,52 @@
1
1
  # chalkbridge
2
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
+
3
50
  ## 1.0.1
4
51
 
5
52
  **Arrows are drawn where they belong, the first time.**
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 }
@@ -1495,6 +1503,6 @@ var stable = (element) => {
1495
1503
  return rest;
1496
1504
  };
1497
1505
 
1498
- 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, noBoundLabel, noBrowser, noScenes, notAPack, notARepo, ok, packInstallFailed, partition, pathOutsideRepo, point, portUnavailable, refusedFieldMessage, sceneFileMissing, staleVersion, storeUnavailable, unknownElement, unknownIcon, unknownScene, unknownSnapshot, unreadableSceneFile };
1499
- //# sourceMappingURL=chunk-SHJT5BF3.js.map
1500
- //# sourceMappingURL=chunk-SHJT5BF3.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, 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-SHJT5BF3.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,6 +1046,7 @@ 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",
@@ -1099,23 +1100,48 @@ function createBridgeClient() {
1099
1100
  return null;
1100
1101
  }
1101
1102
  },
1102
- connect(port) {
1103
- const base = `http://127.0.0.1:${port}`;
1104
- async function call(path, schema, init, timeoutMs = DEFAULT_API_TIMEOUT_MS) {
1105
- 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) {
1106
1121
  try {
1107
- response = await fetch(`${base}${path}`, {
1122
+ return await fetch(`http://127.0.0.1:${current}${path}`, {
1108
1123
  ...init,
1109
1124
  signal: AbortSignal.timeout(timeoutMs)
1110
1125
  });
1111
1126
  } catch {
1112
- return fail(bridgeUnreachable(port));
1127
+ return null;
1113
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
+ }
1138
+ }
1139
+ if (response === null) return fail(bridgeUnreachable(current));
1114
1140
  const body = await response.json().catch(() => null);
1115
- if (!response.ok) return fail(toChalkError(body, port));
1141
+ if (!response.ok) return fail(toChalkError(body, current));
1116
1142
  const parsed = schema.safeParse(body);
1117
1143
  if (!parsed.success) {
1118
- return fail(bridgeUnreachable(port));
1144
+ return fail(bridgeUnreachable(current));
1119
1145
  }
1120
1146
  return ok(parsed.data);
1121
1147
  }
@@ -1726,6 +1752,24 @@ function createBridgeLauncher(options, deps) {
1726
1752
  await sleep(pollIntervalMs);
1727
1753
  }
1728
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;
1729
1773
  }
1730
1774
  };
1731
1775
  }
@@ -1734,6 +1778,48 @@ function createBridgeLauncher(options, deps) {
1734
1778
  function createClock() {
1735
1779
  return { now: () => Date.now() };
1736
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
+ }
1737
1823
  var ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
1738
1824
  var shortId = (length) => {
1739
1825
  let out = "";
@@ -2427,7 +2513,20 @@ function ensureBridge(deps) {
2427
2513
  const repoRoot = await deps.projectDir.findRepoRoot(options.cwd);
2428
2514
  if (!repoRoot.ok) return repoRoot;
2429
2515
  const existingPort = await deps.projectDir.readPort(repoRoot.value);
2430
- 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
+ }
2431
2530
  let port = existing === null ? null : existingPort;
2432
2531
  let health = existing;
2433
2532
  let spawned = false;
@@ -4371,6 +4470,7 @@ function printLine(line) {
4371
4470
  }
4372
4471
  var DEFAULT_PORT = 7180;
4373
4472
  var PORT_SCAN_RANGE = 32;
4473
+ var DEFAULT_IDLE_SECONDS = 90;
4374
4474
  var truthy = (value) => value !== void 0 && value !== "" && value !== "0" && value.toLowerCase() !== "false";
4375
4475
  var EnvSchema = z.object({
4376
4476
  CHALK_PORT: z.coerce.number().int().min(1).max(65535).optional(),
@@ -4383,12 +4483,18 @@ var EnvSchema = z.object({
4383
4483
  * icon pack into the developer real data directory would be a test that changed the
4384
4484
  * machine it ran on.
4385
4485
  */
4386
- 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()
4387
4492
  });
4388
4493
  function readEnv(source) {
4389
4494
  const parsed = EnvSchema.safeParse(source);
4390
4495
  const env = parsed.success ? parsed.data : {};
4391
4496
  const explicit2 = env.CHALK_PORT ?? null;
4497
+ const idleSeconds = env.CHALK_IDLE_TIMEOUT ?? DEFAULT_IDLE_SECONDS;
4392
4498
  return {
4393
4499
  listenFrom: explicit2 ?? DEFAULT_PORT,
4394
4500
  listenTries: explicit2 === null ? PORT_SCAN_RANGE : 1,
@@ -4396,12 +4502,13 @@ function readEnv(source) {
4396
4502
  autoStart: !truthy(env.CHALK_NO_AUTOSTART),
4397
4503
  autoOpen: !truthy(env.CHALK_NO_OPEN),
4398
4504
  dataDir: env.CHALK_DATA_DIR ?? null,
4505
+ idleTimeoutMs: idleSeconds === 0 ? null : idleSeconds * 1e3,
4399
4506
  logLevel: env.CHALK_LOG_LEVEL ?? "info"
4400
4507
  };
4401
4508
  }
4402
4509
 
4403
4510
  // src/composition/container.ts
4404
- var PACKAGE_VERSION = "1.0.1";
4511
+ var PACKAGE_VERSION = "1.0.3";
4405
4512
  function readRuntime() {
4406
4513
  return {
4407
4514
  env: process.env,
@@ -4465,6 +4572,7 @@ function buildCommandContext(runtime = readRuntime()) {
4465
4572
  if (!opened.ok) return opened;
4466
4573
  const store = opened.value;
4467
4574
  const broadcaster = createBroadcaster(idGen, logger);
4575
+ const idleWatch = createIdleWatch({ timeoutMs: config.idleTimeoutMs }, { clock, logger });
4468
4576
  const presenceTracker = createPresenceTracker();
4469
4577
  const expander = createNodeSkeletonExpander();
4470
4578
  const repoWriter = createRepoWriter({ repoRoot }, { store, logger });
@@ -4472,6 +4580,7 @@ function buildCommandContext(runtime = readRuntime()) {
4472
4580
  const diskWatcher = createDiskWatcher({ logger });
4473
4581
  const commit = commitScene({ store, repoWriter });
4474
4582
  const repoFiles = createRepoFiles({ repoRoot });
4583
+ let onShutdown = null;
4475
4584
  const exportBroker = createExportBroker({}, { broadcaster, idGen, logger });
4476
4585
  const globalPaths = globalPathsOf(runtime, config);
4477
4586
  let catalog = null;
@@ -4635,6 +4744,10 @@ function buildCommandContext(runtime = readRuntime()) {
4635
4744
  // logic in `app/`, and a lifecycle command has no more business holding a store
4636
4745
  // than a route does.
4637
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
+ },
4638
4751
  close: () => {
4639
4752
  repoWriter.cancel();
4640
4753
  exportBroker.abandon();
@@ -4675,6 +4788,7 @@ function buildCommandContext(runtime = readRuntime()) {
4675
4788
  importScene: usecases.importScene,
4676
4789
  broadcaster,
4677
4790
  identity,
4791
+ requestShutdown: () => onShutdown?.(),
4678
4792
  logger
4679
4793
  },
4680
4794
  ws: {
@@ -4698,24 +4812,42 @@ function buildCommandContext(runtime = readRuntime()) {
4698
4812
  { cliEntry: runtime.entryPath, execPath: runtime.execPath, env: runtime.env },
4699
4813
  { projectDir, client, logger }
4700
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
+ });
4701
4825
  return {
4702
4826
  logger,
4703
4827
  projectDir,
4704
4828
  client,
4829
+ launcher,
4705
4830
  // A7 — the tool handlers never see a port, a URL, or a `fetch`. They see the
4706
4831
  // bridge's read surface and a shelf of markdown, and nothing else exists to
4707
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.
4708
4839
  createToolDeps: (port) => ({
4709
- 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
+ }),
4710
4848
  guides: createGuideLibrary({ dir: resolveGuidesDir() }, { logger })
4711
4849
  }),
4712
- ensureBridge: ensureBridge({
4713
- projectDir,
4714
- client,
4715
- launcher,
4716
- opener: createBrowserOpener(logger),
4717
- logger
4718
- })
4850
+ ensureBridge: bootstrap
4719
4851
  };
4720
4852
  }
4721
4853
  };
@@ -5702,6 +5834,8 @@ var STATUS = {
5702
5834
  BRIDGE_SPAWN_FAILED: 500,
5703
5835
  BRIDGE_UNREACHABLE: 503,
5704
5836
  PORT_UNAVAILABLE: 500,
5837
+ // 409: something is there, it is simply not the build this process belongs to.
5838
+ BRIDGE_VERSION_MISMATCH: 409,
5705
5839
  // 409 for both: a stale version and a file that changed underneath us are the same
5706
5840
  // shape of problem — the caller's view of the world is out of date, and re-reading is
5707
5841
  // the way forward.
@@ -5960,6 +6094,20 @@ var sessionRoutes = [
5960
6094
  })
5961
6095
  ];
5962
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
+
5963
6111
  // src/interfaces/http/routes/write.ts
5964
6112
  var writeRoutes = [
5965
6113
  postRoute({
@@ -6045,6 +6193,7 @@ var ROUTES = [
6045
6193
  elementsRoute,
6046
6194
  settingsRoute,
6047
6195
  debugElementRoute,
6196
+ shutdownRoute,
6048
6197
  ...writeRoutes,
6049
6198
  ...sessionRoutes
6050
6199
  ];
@@ -6058,6 +6207,9 @@ async function createHttpServer(deps) {
6058
6207
  bodyLimit: 32 * 1024 * 1024
6059
6208
  });
6060
6209
  await app.register(websocket);
6210
+ app.addHook("onRequest", async () => {
6211
+ deps.touch();
6212
+ });
6061
6213
  for (const route of ROUTES) {
6062
6214
  route.register(app, deps.routes);
6063
6215
  deps.logger.debug("registered route", { route: route.name });
@@ -6067,6 +6219,7 @@ async function createHttpServer(deps) {
6067
6219
  const send = (message) => {
6068
6220
  socket.send(JSON.stringify(message));
6069
6221
  };
6222
+ deps.touch();
6070
6223
  const handle = deps.broadcaster.register(send);
6071
6224
  const session = { handle, send };
6072
6225
  socket.on("message", (data) => {
@@ -6147,7 +6300,8 @@ ${built.error.hint ?? ""}
6147
6300
  ws: services.ws,
6148
6301
  broadcaster: services.broadcaster,
6149
6302
  logger: services.logger,
6150
- uiDistPath: services.uiDistPath
6303
+ uiDistPath: services.uiDistPath,
6304
+ touch: () => services.idleWatch.touch()
6151
6305
  });
6152
6306
  const listening = await listenOnFirstFreePort(app, {
6153
6307
  host: "127.0.0.1",
@@ -6174,6 +6328,7 @@ ${built.error.hint ?? ""}
6174
6328
  await new Promise((resolve6) => {
6175
6329
  const shutdown = async (signal) => {
6176
6330
  services.logger.info("shutting down", { signal });
6331
+ services.idleWatch.stop();
6177
6332
  await services.diskWatcher.stop();
6178
6333
  const flushed = await services.repoWriter.flushAll();
6179
6334
  if (flushed.length > 0) {
@@ -6184,18 +6339,75 @@ ${built.error.hint ?? ""}
6184
6339
  services.close();
6185
6340
  resolve6();
6186
6341
  };
6342
+ services.idleWatch.start({
6343
+ isWatched: () => services.broadcaster.clientCount() > 0,
6344
+ onIdle: () => void shutdown("idle")
6345
+ });
6187
6346
  process.once("SIGINT", () => void shutdown("SIGINT"));
6188
6347
  process.once("SIGTERM", () => void shutdown("SIGTERM"));
6348
+ services.onShutdownRequest(() => void shutdown("api"));
6189
6349
  });
6190
6350
  return 0;
6191
6351
  }
6192
6352
  };
6193
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
+
6194
6405
  // src/interfaces/cli/registry.ts
6195
6406
  var COMMANDS = [
6196
6407
  serveCommand,
6197
6408
  mcpCommand,
6198
6409
  healthCommand,
6410
+ stopCommand,
6199
6411
  iconsCommand
6200
6412
  ];
6201
6413
 
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { createNodeSkeletonExpander, fail, isFail, isOk, ok, partition } from './chunk-SHJT5BF3.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.1",
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",