@inerrata-corporation/errata 2.0.23-dev.4779 → 2.0.23-dev.4803

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/errata.mjs CHANGED
@@ -37887,26 +37887,26 @@ function buildFileLexicon(store) {
37887
37887
  for (const n of store.findNodeFieldsByLabel("File", ["relPath"])) {
37888
37888
  const relPath = typeof n.attrs["relPath"] === "string" && n.attrs["relPath"].length > 0 ? n.attrs["relPath"] : n.description;
37889
37889
  if (!relPath) continue;
37890
- const basename10 = relPath.split("/").pop();
37891
- if (!basename10.includes(".")) continue;
37892
- entries.push({ relPath, basename: basename10 });
37893
- basenameCount.set(basename10, (basenameCount.get(basename10) ?? 0) + 1);
37894
- const stem = basename10.slice(0, basename10.indexOf("."));
37890
+ const basename11 = relPath.split("/").pop();
37891
+ if (!basename11.includes(".")) continue;
37892
+ entries.push({ relPath, basename: basename11 });
37893
+ basenameCount.set(basename11, (basenameCount.get(basename11) ?? 0) + 1);
37894
+ const stem = basename11.slice(0, basename11.indexOf("."));
37895
37895
  if (isBareMatchableStem(stem)) {
37896
37896
  let s = stemRelPaths.get(stem);
37897
37897
  if (!s) stemRelPaths.set(stem, s = /* @__PURE__ */ new Set());
37898
37898
  s.add(relPath);
37899
37899
  }
37900
37900
  }
37901
- for (const { relPath, basename: basename10 } of entries) {
37901
+ for (const { relPath, basename: basename11 } of entries) {
37902
37902
  const entry = { relPath };
37903
- if (relPath !== basename10) byKey.set(relPath, entry);
37904
- const conventional = CONVENTION_FILENAMES.has(basename10) || (basenameCount.get(basename10) ?? 0) >= CONVENTION_BASENAME_MIN_FILES;
37905
- if (!conventional && !byKey.has(basename10)) byKey.set(basename10, entry);
37906
- const list = byBasename.get(basename10);
37903
+ if (relPath !== basename11) byKey.set(relPath, entry);
37904
+ const conventional = CONVENTION_FILENAMES.has(basename11) || (basenameCount.get(basename11) ?? 0) >= CONVENTION_BASENAME_MIN_FILES;
37905
+ if (!conventional && !byKey.has(basename11)) byKey.set(basename11, entry);
37906
+ const list = byBasename.get(basename11);
37907
37907
  if (list) list.push(entry);
37908
- else byBasename.set(basename10, [entry]);
37909
- const stem = basename10.slice(0, basename10.indexOf("."));
37908
+ else byBasename.set(basename11, [entry]);
37909
+ const stem = basename11.slice(0, basename11.indexOf("."));
37910
37910
  if (isBareMatchableStem(stem) && !CONVENTION_STEMS.has(stem) && !byStem.has(stem)) {
37911
37911
  const dirStems = new Set(
37912
37912
  [...stemRelPaths.get(stem) ?? []].map((p) => {
@@ -37923,8 +37923,8 @@ function resolveFileToken(token, lexicon) {
37923
37923
  const exact = lexicon.byKey.get(token);
37924
37924
  if (exact) return exact;
37925
37925
  if (!token.includes("/")) return null;
37926
- const basename10 = token.split("/").pop();
37927
- for (const entry of lexicon.byBasename.get(basename10) ?? []) {
37926
+ const basename11 = token.split("/").pop();
37927
+ for (const entry of lexicon.byBasename.get(basename11) ?? []) {
37928
37928
  if (entry.relPath === token || entry.relPath.endsWith(`/${token}`)) return entry;
37929
37929
  }
37930
37930
  return null;
@@ -37949,9 +37949,9 @@ function fileRolePhrase(relPath, level = 1) {
37949
37949
  return "a source file";
37950
37950
  }
37951
37951
  function isSourceFileToken(token) {
37952
- const basename10 = token.split(/[/\\]/).pop() ?? token;
37953
- if (CONVENTION_FILENAMES.has(basename10)) return false;
37954
- const ext = basename10.split(".").pop();
37952
+ const basename11 = token.split(/[/\\]/).pop() ?? token;
37953
+ if (CONVENTION_FILENAMES.has(basename11)) return false;
37954
+ const ext = basename11.split(".").pop();
37955
37955
  return ext !== void 0 && SOURCE_EXTENSIONS.has(ext.toLowerCase());
37956
37956
  }
37957
37957
  function generalizeFileRefs(text, fileLexicon, level = 1) {
@@ -57729,6 +57729,7 @@ var init_message_accumulator = __esm({
57729
57729
  var webui_exports = {};
57730
57730
  __export(webui_exports, {
57731
57731
  ContextBaselines: () => ContextBaselines,
57732
+ FOCUS_NOTICE_FIELD: () => FOCUS_NOTICE_FIELD,
57732
57733
  SUBAGENT_REFRESH_MIN_MS: () => SUBAGENT_REFRESH_MIN_MS,
57733
57734
  asFailedPostToolUse: () => asFailedPostToolUse,
57734
57735
  buildWebUi: () => buildWebUi,
@@ -58438,7 +58439,9 @@ ${delta}${cue}`;
58438
58439
  block = own ?? readContextFileWithHash(deps.paths.root);
58439
58440
  storedCard = own ? own.card : block ? readContextCard(deps.paths.root, block.hash) : null;
58440
58441
  }
58441
- const decision = decideContextInject({
58442
+ const focusNotice = typeof body2[FOCUS_NOTICE_FIELD] === "string" ? body2[FOCUS_NOTICE_FIELD] : null;
58443
+ if (focusNotice && contextKey2) lastInjectedHash.reset(contextKey2, "focus");
58444
+ const decided = decideContextInject({
58442
58445
  event: hookEvent,
58443
58446
  source: String(body2["source"] ?? ""),
58444
58447
  sessionId: contextKey2,
@@ -58448,6 +58451,9 @@ ${delta}${cue}`;
58448
58451
  // A subagent's injections land in its own transcript, not the one posted.
58449
58452
  ...subagent === null && typeof body2["transcript_path"] === "string" && body2["transcript_path"] ? { transcriptPath: body2["transcript_path"] } : {}
58450
58453
  });
58454
+ const decision = focusNotice ? { hookEventName: decided?.hookEventName ?? hookEvent, additionalContext: decided ? `${focusNotice}
58455
+
58456
+ ${decided.additionalContext}` : focusNotice } : decided;
58451
58457
  if (decision && subagent !== null) lastSubagentInjectAt.set(contextKey2, Date.now());
58452
58458
  let systemMessage = null;
58453
58459
  const delivered = decision !== null && decision.additionalContext !== PROMPT_RECUE;
@@ -58646,7 +58652,7 @@ function proposalToIngest(p, profile, daemonVersion) {
58646
58652
  payloadDigest: p.id
58647
58653
  };
58648
58654
  }
58649
- var messageAccumulator, GENERIC_ERROR_WORDS, wsPackagesCache, WS_PACKAGES_TTL_MS, loggedToolSessions, SUBAGENT_REFRESH_MIN_MS, WINDOW_RESET_SOURCES, FULL_BLOCK_PREFIX, META_LABELS;
58655
+ var messageAccumulator, GENERIC_ERROR_WORDS, wsPackagesCache, WS_PACKAGES_TTL_MS, loggedToolSessions, SUBAGENT_REFRESH_MIN_MS, WINDOW_RESET_SOURCES, FULL_BLOCK_PREFIX, FOCUS_NOTICE_FIELD, META_LABELS;
58650
58656
  var init_webui = __esm({
58651
58657
  "src/webui.ts"() {
58652
58658
  "use strict";
@@ -58713,6 +58719,7 @@ var init_webui = __esm({
58713
58719
  SUBAGENT_REFRESH_MIN_MS = 6e4;
58714
58720
  WINDOW_RESET_SOURCES = /* @__PURE__ */ new Set(["compact", "clear", "resume"]);
58715
58721
  FULL_BLOCK_PREFIX = "The errata daemon has updated your live workspace context. The block below REPLACES any earlier errata context block in this conversation \u2014 treat it as the current source of truth:\n\n";
58722
+ FOCUS_NOTICE_FIELD = "errata_focus_notice";
58716
58723
  META_LABELS = /* @__PURE__ */ new Set([
58717
58724
  "Pattern",
58718
58725
  "Technique",
@@ -78858,10 +78865,10 @@ var ReaddirpStream = class extends Readable2 {
78858
78865
  }
78859
78866
  async _formatEntry(dirent, path3) {
78860
78867
  let entry;
78861
- const basename10 = this._isDirent ? dirent.name : dirent;
78868
+ const basename11 = this._isDirent ? dirent.name : dirent;
78862
78869
  try {
78863
- const fullPath = presolve(pjoin(path3, basename10));
78864
- entry = { path: prelative(this._root, fullPath), fullPath, basename: basename10 };
78870
+ const fullPath = presolve(pjoin(path3, basename11));
78871
+ entry = { path: prelative(this._root, fullPath), fullPath, basename: basename11 };
78865
78872
  entry[this._statsProp] = this._isDirent ? dirent : await this._stat(fullPath);
78866
78873
  } catch (err2) {
78867
78874
  this._onError(err2);
@@ -79400,9 +79407,9 @@ var NodeFsHandler = class {
79400
79407
  _watchWithNodeFs(path3, listener) {
79401
79408
  const opts = this.fsw.options;
79402
79409
  const directory = sysPath.dirname(path3);
79403
- const basename10 = sysPath.basename(path3);
79410
+ const basename11 = sysPath.basename(path3);
79404
79411
  const parent = this.fsw._getWatchedDir(directory);
79405
- parent.add(basename10);
79412
+ parent.add(basename11);
79406
79413
  const absolutePath = sysPath.resolve(path3);
79407
79414
  const options = {
79408
79415
  persistent: opts.persistent
@@ -79412,7 +79419,7 @@ var NodeFsHandler = class {
79412
79419
  let closer;
79413
79420
  if (opts.usePolling) {
79414
79421
  const enableBin = opts.interval !== opts.binaryInterval;
79415
- options.interval = enableBin && isBinaryPath(basename10) ? opts.binaryInterval : opts.interval;
79422
+ options.interval = enableBin && isBinaryPath(basename11) ? opts.binaryInterval : opts.interval;
79416
79423
  closer = setFsWatchFileListener(path3, absolutePath, options, {
79417
79424
  listener,
79418
79425
  rawEmitter: this.fsw._emitRaw
@@ -79435,10 +79442,10 @@ var NodeFsHandler = class {
79435
79442
  return;
79436
79443
  }
79437
79444
  const dirname21 = sysPath.dirname(file2);
79438
- const basename10 = sysPath.basename(file2);
79445
+ const basename11 = sysPath.basename(file2);
79439
79446
  const parent = this.fsw._getWatchedDir(dirname21);
79440
79447
  let prevStats = stats;
79441
- if (parent.has(basename10))
79448
+ if (parent.has(basename11))
79442
79449
  return;
79443
79450
  const listener = async (path3, newStats) => {
79444
79451
  if (!this.fsw._throttle(THROTTLE_MODE_WATCH, file2, 5))
@@ -79463,9 +79470,9 @@ var NodeFsHandler = class {
79463
79470
  prevStats = newStats2;
79464
79471
  }
79465
79472
  } catch (error48) {
79466
- this.fsw._remove(dirname21, basename10);
79473
+ this.fsw._remove(dirname21, basename11);
79467
79474
  }
79468
- } else if (parent.has(basename10)) {
79475
+ } else if (parent.has(basename11)) {
79469
79476
  const at = newStats.atimeMs;
79470
79477
  const mt = newStats.mtimeMs;
79471
79478
  if (!at || at <= mt || mt !== prevStats.mtimeMs) {
@@ -87520,7 +87527,7 @@ function createWatchBreaker(deps) {
87520
87527
  }
87521
87528
 
87522
87529
  // src/engine.ts
87523
- var DAEMON_VERSION = true ? "2.0.23-dev.4779" : "2.0.0-alpha.0";
87530
+ var DAEMON_VERSION = true ? "2.0.23-dev.4803" : "2.0.0-alpha.0";
87524
87531
  var IGNORED_PATH = /[\\/](?:\.git|node_modules|\.errata|\.claude|\.codex|\.cursor|\.turbo|\.next|dist|coverage|test-results|playwright-report|__pycache__)(?:[\\/]|$)/;
87525
87532
  var IGNORED_NOISE = /castalia\.db|eventlog\.sqlite|turn-cursors/;
87526
87533
  var GIT_OP_MUTE_MS = 4e3;
@@ -87563,15 +87570,43 @@ function loadTurnOffsets(path3) {
87563
87570
  return /* @__PURE__ */ new Map();
87564
87571
  }
87565
87572
  }
87566
- function saveTurnCursors(path3, cursors, offsets) {
87573
+ function loadTurnMoves(path3) {
87574
+ try {
87575
+ const raw2 = JSON.parse(readFileSync33(path3, "utf8"));
87576
+ const out2 = /* @__PURE__ */ new Map();
87577
+ for (const [k, v] of Object.entries(raw2)) {
87578
+ const to = typeof v === "object" && v !== null ? v.movedTo : void 0;
87579
+ if (typeof to === "string" && to) out2.set(k, to);
87580
+ }
87581
+ return out2;
87582
+ } catch {
87583
+ return /* @__PURE__ */ new Map();
87584
+ }
87585
+ }
87586
+ function saveTurnCursors(path3, cursors, offsets, moves = /* @__PURE__ */ new Map()) {
87567
87587
  try {
87568
87588
  const merged = {};
87569
87589
  for (const [k, uuid3] of cursors) merged[k] = { uuid: uuid3, offset: offsets.get(k) ?? 0 };
87570
87590
  for (const [k, offset] of offsets) if (!merged[k]) merged[k] = { uuid: "", offset };
87591
+ for (const [k, movedTo] of moves) merged[k] = { ...merged[k] ?? { uuid: "", offset: 0 }, movedTo };
87571
87592
  writeFileSync24(path3, JSON.stringify(merged), "utf8");
87572
87593
  } catch {
87573
87594
  }
87574
87595
  }
87596
+ var turnCursorFile = (configDir) => join37(configDir, "turn-cursors.json");
87597
+ function turnCursorOnDisk(configDir, sessionId) {
87598
+ const path3 = turnCursorFile(configDir);
87599
+ const offset = loadTurnOffsets(path3).get(sessionId);
87600
+ const movedTo = loadTurnMoves(path3).get(sessionId);
87601
+ if (offset === void 0 && movedTo === void 0) return null;
87602
+ return { uuid: loadTurnCursors(path3).get(sessionId) ?? "", offset: offset ?? 0, ...movedTo ? { movedTo } : {} };
87603
+ }
87604
+ function releaseTurnCursorOnDisk(configDir, sessionId, to) {
87605
+ const path3 = turnCursorFile(configDir);
87606
+ const moves = loadTurnMoves(path3);
87607
+ moves.set(sessionId, to);
87608
+ saveTurnCursors(path3, loadTurnCursors(path3), loadTurnOffsets(path3), moves);
87609
+ }
87575
87610
  function gitSourceWatchTargets(root) {
87576
87611
  const SOURCE = /\.(ts|tsx|js|jsx|mjs|cjs|py)$/i;
87577
87612
  try {
@@ -88578,9 +88613,10 @@ function createWorkspaceEngine(opts) {
88578
88613
  resultSummary: { exitCode: e.exitCode ?? 1, errorTokens: e.errorTokens }
88579
88614
  });
88580
88615
  };
88581
- const turnCursorPath = join37(paths.configDir, "turn-cursors.json");
88616
+ const turnCursorPath = turnCursorFile(paths.configDir);
88582
88617
  const lastTurnUuid = loadTurnCursors(turnCursorPath);
88583
88618
  const turnOffset = loadTurnOffsets(turnCursorPath);
88619
+ const turnMovedTo = loadTurnMoves(turnCursorPath);
88584
88620
  const readLosses = { parseFailures: 0, ioFailures: 0, bytesUnreachable: 0, sliceGuardHits: 0 };
88585
88621
  const sessionLastProblem = /* @__PURE__ */ new Map();
88586
88622
  const sessionThreads = /* @__PURE__ */ new Map();
@@ -89588,7 +89624,7 @@ function createWorkspaceEngine(opts) {
89588
89624
  lastTurnUuid.set(sessionId, turns[turns.length - 1].uuid);
89589
89625
  await harvestTexts(sessionId, fresh);
89590
89626
  }
89591
- saveTurnCursors(turnCursorPath, lastTurnUuid, turnOffset);
89627
+ saveTurnCursors(turnCursorPath, lastTurnUuid, turnOffset, turnMovedTo);
89592
89628
  if (slice >= 256) {
89593
89629
  readLosses.sliceGuardHits++;
89594
89630
  console.warn("[errata] harvest slice guard hit \u2014 backlog resumes next tick");
@@ -89605,7 +89641,7 @@ function createWorkspaceEngine(opts) {
89605
89641
  if (next.losses.ioFailed) readLosses.ioFailures++;
89606
89642
  if (next.turns.length === 0 && !next.more) {
89607
89643
  turnOffset.set(sessionId, next.nextOffset);
89608
- saveTurnCursors(turnCursorPath, lastTurnUuid, turnOffset);
89644
+ saveTurnCursors(turnCursorPath, lastTurnUuid, turnOffset, turnMovedTo);
89609
89645
  break;
89610
89646
  }
89611
89647
  turns = next.turns;
@@ -89659,6 +89695,7 @@ function createWorkspaceEngine(opts) {
89659
89695
  }
89660
89696
  };
89661
89697
  const harvestSession = async (sessionId, transcriptPath) => {
89698
+ if (turnMovedTo.has(sessionId)) return;
89662
89699
  await harvestTurns(sessionId, transcriptPath);
89663
89700
  for (const sub of subagentTranscripts(transcriptPath, sessionId)) {
89664
89701
  await yieldToLoop();
@@ -89894,6 +89931,28 @@ B: ${b}`,
89894
89931
  storeReconciled: () => storeReconciled,
89895
89932
  lastGitEventAt: () => lastGitEventTs,
89896
89933
  quarantinedSessions: () => sessions.originSessions(),
89934
+ turnCursor(sessionId) {
89935
+ const offset = turnOffset.get(sessionId);
89936
+ const movedTo = turnMovedTo.get(sessionId);
89937
+ if (offset === void 0 && movedTo === void 0) return null;
89938
+ return { uuid: lastTurnUuid.get(sessionId) ?? "", offset: offset ?? 0, ...movedTo ? { movedTo } : {} };
89939
+ },
89940
+ releaseTurnCursor(sessionId, to) {
89941
+ turnMovedTo.set(sessionId, to);
89942
+ saveTurnCursors(turnCursorPath, lastTurnUuid, turnOffset, turnMovedTo);
89943
+ },
89944
+ adoptTurnCursor(sessionId, cursor) {
89945
+ turnMovedTo.delete(sessionId);
89946
+ if (cursor && cursor.offset > (turnOffset.get(sessionId) ?? -1)) {
89947
+ turnOffset.set(sessionId, cursor.offset);
89948
+ if (cursor.uuid) lastTurnUuid.set(sessionId, cursor.uuid);
89949
+ }
89950
+ saveTurnCursors(turnCursorPath, lastTurnUuid, turnOffset, turnMovedTo);
89951
+ },
89952
+ async settleTurns(sessionId) {
89953
+ await (harvestInFlight.get(sessionId) ?? Promise.resolve()).catch(() => {
89954
+ });
89955
+ },
89897
89956
  markContextDirty() {
89898
89957
  contextDirty = true;
89899
89958
  refreshContextNow();
@@ -90943,7 +91002,7 @@ function invalidateInstanceReceipt(current, destination, failedId, mirrorLegacy)
90943
91002
  init_dist();
90944
91003
  init_src5();
90945
91004
  import { existsSync as existsSync34, readFileSync as readFileSync40, writeFileSync as writeFileSync29 } from "node:fs";
90946
- import { dirname as dirname17, join as join47, resolve as pathResolve } from "node:path";
91005
+ import { basename as basename10, dirname as dirname17, join as join47, resolve as pathResolve } from "node:path";
90947
91006
 
90948
91007
  // src/principle-sync.ts
90949
91008
  init_src5();
@@ -95425,6 +95484,15 @@ function absolutePathsInCommand(command, platform = process.platform) {
95425
95484
  }
95426
95485
  return out2;
95427
95486
  }
95487
+ function leadingCdTarget(command, platform = process.platform) {
95488
+ if (typeof command !== "string") return void 0;
95489
+ const m = /^\s*(?:cd|pushd|Set-Location)\s+("[^"]+"|'[^']+'|[^\s;&|]+)\s*(?:&&|;|\|\||$)/.exec(command);
95490
+ if (!m) return void 0;
95491
+ const target = m[1].replace(/^["']|["']$/g, "");
95492
+ if (/^[a-zA-Z]:[\\/]/.test(target)) return target;
95493
+ if (platform === "win32") return /^\/[a-zA-Z](\/|$)/.test(target) ? msysToWindowsPath(target, platform) : void 0;
95494
+ return target.startsWith("/") ? target : void 0;
95495
+ }
95428
95496
  function hookPathCandidates(body2, platform = process.platform) {
95429
95497
  const input = body2["tool_input"] ?? {};
95430
95498
  const str = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
@@ -95470,6 +95538,205 @@ var SessionAffinity = class {
95470
95538
  if (typeof sessionId === "string") this.bySession.delete(sessionId);
95471
95539
  }
95472
95540
  };
95541
+ var FOCUS_WINDOW = 5;
95542
+ var FOCUS_ROOT_HEADER = "x-errata-focus-root";
95543
+ var FOCUS_QUORUM = 3;
95544
+ var FOCUS_TTL_MS = 6 * 60 * 6e4;
95545
+ var SessionFocus = class {
95546
+ constructor(ttlMs = FOCUS_TTL_MS, maxSessions = 500) {
95547
+ this.ttlMs = ttlMs;
95548
+ this.maxSessions = maxSessions;
95549
+ }
95550
+ bySession = /* @__PURE__ */ new Map();
95551
+ live(sessionId, now) {
95552
+ if (typeof sessionId !== "string" || sessionId.length === 0) return null;
95553
+ const s = this.bySession.get(sessionId);
95554
+ if (!s) return null;
95555
+ if (now - s.at > this.ttlMs) {
95556
+ this.bySession.delete(sessionId);
95557
+ return null;
95558
+ }
95559
+ return s;
95560
+ }
95561
+ touch(sessionId, s, now) {
95562
+ s.at = now;
95563
+ this.bySession.delete(sessionId);
95564
+ this.bySession.set(sessionId, s);
95565
+ while (this.bySession.size > this.maxSessions) {
95566
+ const oldest = this.bySession.keys().next().value;
95567
+ if (oldest === void 0) break;
95568
+ this.bySession.delete(oldest);
95569
+ }
95570
+ }
95571
+ /** The workspace this session's hooks should go to instead of its cwd's, if any. */
95572
+ focusFor(sessionId, now = Date.now()) {
95573
+ return this.live(sessionId, now)?.focus ?? null;
95574
+ }
95575
+ pinnedFor(sessionId, now = Date.now()) {
95576
+ return this.live(sessionId, now)?.pinned ?? false;
95577
+ }
95578
+ /** A tool hook's locator reached `root`; `cwdRoot` is where the session's cwd routes. */
95579
+ observe(sessionId, root, cwdRoot, now = Date.now()) {
95580
+ if (typeof sessionId !== "string" || sessionId.length === 0) return null;
95581
+ const s = this.live(sessionId, now) ?? { recent: [], focus: null, pinned: false, at: now, reached: /* @__PURE__ */ new Set() };
95582
+ s.reached.add(root);
95583
+ s.recent.push(root);
95584
+ if (s.recent.length > FOCUS_WINDOW) s.recent.shift();
95585
+ this.touch(sessionId, s, now);
95586
+ if (s.pinned) return null;
95587
+ const current = s.focus ?? cwdRoot;
95588
+ if (root === current) return null;
95589
+ if (s.recent.filter((r) => r === root).length < FOCUS_QUORUM) return null;
95590
+ s.focus = root === cwdRoot ? null : root;
95591
+ return { from: current, to: root, reason: "inferred" };
95592
+ }
95593
+ pin(sessionId, root, cwdRoot, now = Date.now()) {
95594
+ if (typeof sessionId !== "string" || sessionId.length === 0) return null;
95595
+ const s = this.live(sessionId, now) ?? { recent: [], focus: null, pinned: false, at: now, reached: /* @__PURE__ */ new Set() };
95596
+ const current = s.focus ?? cwdRoot;
95597
+ s.pinned = true;
95598
+ s.focus = root;
95599
+ s.recent = [];
95600
+ this.touch(sessionId, s, now);
95601
+ return root === current ? null : { from: current, to: root, reason: "pinned" };
95602
+ }
95603
+ /** Drop the pin and any inferred focus: the session follows its cwd again. */
95604
+ unpin(sessionId, cwdRoot, now = Date.now()) {
95605
+ const s = this.live(sessionId, now);
95606
+ if (!s) return null;
95607
+ const current = s.focus ?? cwdRoot;
95608
+ s.pinned = false;
95609
+ s.focus = null;
95610
+ s.recent = [];
95611
+ this.touch(sessionId, s, now);
95612
+ return cwdRoot && current !== cwdRoot ? { from: current, to: cwdRoot, reason: "unpinned" } : null;
95613
+ }
95614
+ /** A tool ran in the session's cwd without naming a path (a shell command, a search). */
95615
+ ranIn(sessionId, root, now = Date.now()) {
95616
+ if (typeof sessionId !== "string" || sessionId.length === 0) return;
95617
+ const s = this.live(sessionId, now) ?? { recent: [], focus: null, pinned: false, at: now, reached: /* @__PURE__ */ new Set() };
95618
+ s.reached.add(root);
95619
+ this.touch(sessionId, s, now);
95620
+ }
95621
+ /** Whether the session's tools ever worked in `root`; undefined for a session this daemon has not watched. */
95622
+ reached(sessionId, root, now = Date.now()) {
95623
+ return this.live(sessionId, now)?.reached.has(root);
95624
+ }
95625
+ forget(sessionId) {
95626
+ if (typeof sessionId === "string") this.bySession.delete(sessionId);
95627
+ }
95628
+ /** Every live focus, for `errata focus` to confirm a pin landed. */
95629
+ entries(now = Date.now()) {
95630
+ const out2 = [];
95631
+ for (const [sessionId, s] of this.bySession) {
95632
+ if (now - s.at > this.ttlMs || !s.focus) continue;
95633
+ out2.push({ sessionId, root: s.focus, pinned: s.pinned, at: s.at });
95634
+ }
95635
+ return out2;
95636
+ }
95637
+ };
95638
+ function misfiledTarget(roots, home) {
95639
+ if (roots.includes(home)) return null;
95640
+ const tally = /* @__PURE__ */ new Map();
95641
+ for (const r of roots) tally.set(r, (tally.get(r) ?? 0) + 1);
95642
+ const ranked = [...tally].sort((a, b) => b[1] - a[1]);
95643
+ const [top, next] = ranked;
95644
+ if (!top || top[1] < FOCUS_QUORUM || next && next[1] === top[1]) return null;
95645
+ return top[0];
95646
+ }
95647
+ function focusCommandIn(command) {
95648
+ if (typeof command !== "string") return void 0;
95649
+ const m = /(?:^|[\s;&|(])errata(?:\.cmd|\.ps1)?\s+focus(?:\s+("[^"]+"|'[^']+'|[^\s;&|)]+))?/.exec(command);
95650
+ if (!m) return void 0;
95651
+ const arg = m[1]?.replace(/^["']|["']$/g, "");
95652
+ if (!arg) return void 0;
95653
+ if (arg === "--clear" || arg === "clear" || arg === "off") return { ref: null };
95654
+ return arg.startsWith("-") ? void 0 : { ref: arg };
95655
+ }
95656
+ function resolveWorkspaceRef(ref, entries, ownerRoot) {
95657
+ const byName = entries.filter((e) => e.name.toLowerCase() === ref.toLowerCase());
95658
+ if (byName.length === 1) return byName[0];
95659
+ if (byName.length > 1) return null;
95660
+ const root = ownerRoot(entries.map((e) => e.path), msysToWindowsPath(ref));
95661
+ return root ? entries.find((e) => e.path === root) ?? null : null;
95662
+ }
95663
+
95664
+ // src/rehome.ts
95665
+ init_src2();
95666
+ import { isAbsolute as isAbsolute6 } from "node:path";
95667
+ function sourcesOf(n) {
95668
+ const s = n.attrs["sources"];
95669
+ return Array.isArray(s) ? s.filter((x) => typeof x === "string") : [];
95670
+ }
95671
+ function rehomeSessionNodes(from, to, sessionIds, opts) {
95672
+ const sessions = new Set(sessionIds);
95673
+ const now = opts.now ?? Date.now();
95674
+ const report = { moved: [], copied: [], vocabulary: [], edgesCarried: 0, edgesDropped: 0, cloudContributed: [] };
95675
+ const picked = /* @__PURE__ */ new Map();
95676
+ for (const row of from.selectNodes({ labels: SEMANTIC_NODE_LABELS, attrs: ["sources"] })) {
95677
+ const sources = sourcesOf(row);
95678
+ if (!sources.some((s) => sessions.has(s))) continue;
95679
+ const node2 = from.getNode(row.id);
95680
+ if (!node2) continue;
95681
+ picked.set(node2.id, node2);
95682
+ (sources.every((s) => sessions.has(s)) ? report.moved : report.copied).push(node2.id);
95683
+ if (node2.attrs["cloudNodeId"]) report.cloudContributed.push(node2.id);
95684
+ }
95685
+ const edges = /* @__PURE__ */ new Map();
95686
+ for (const id of picked.keys()) {
95687
+ for (const e of [...from.outEdges(id), ...from.inEdges(id)]) edges.set(e.id, e);
95688
+ }
95689
+ const vocabulary = /* @__PURE__ */ new Map();
95690
+ const carried = [];
95691
+ for (const e of edges.values()) {
95692
+ const other = picked.has(e.from) ? e.to : e.from;
95693
+ if (picked.has(other)) {
95694
+ carried.push(e);
95695
+ continue;
95696
+ }
95697
+ const node2 = from.getNode(other);
95698
+ if (!node2 || isCodeLabel(node2.label)) {
95699
+ report.edgesDropped++;
95700
+ continue;
95701
+ }
95702
+ if (!to.getNode(other)) vocabulary.set(other, node2);
95703
+ carried.push(e);
95704
+ }
95705
+ report.vocabulary = [...vocabulary.keys()];
95706
+ report.edgesCarried = carried.length;
95707
+ if (!opts.apply) return report;
95708
+ to.transaction(() => {
95709
+ for (const n of [...vocabulary.values(), ...picked.values()]) to.mergeNode(n);
95710
+ for (const e of carried) to.mergeEdge(e);
95711
+ });
95712
+ from.transaction(() => {
95713
+ for (const id of report.moved) {
95714
+ const n = picked.get(id);
95715
+ from.updateNode(id, { attrs: { ...n.attrs, rehomedAt: now } });
95716
+ from.closeNode(id, now);
95717
+ }
95718
+ });
95719
+ return report;
95720
+ }
95721
+ function captureWorkPaths(store, home) {
95722
+ const out2 = /* @__PURE__ */ new Map();
95723
+ for (const row of store.selectNodes({ labels: ["Problem"], attrs: ["sources", "anchorHint"] })) {
95724
+ const sources = sourcesOf(row);
95725
+ if (sources.length === 0) continue;
95726
+ let where = null;
95727
+ if (store.outEdges(row.id, ["ANCHORED_AT"]).length > 0) where = home;
95728
+ else {
95729
+ const hint = row.attrs["anchorHint"];
95730
+ if (typeof hint?.path === "string" && hint.path.length > 0) where = isAbsolute6(hint.path) ? hint.path : home;
95731
+ }
95732
+ for (const s of sources) {
95733
+ const list = out2.get(s) ?? [];
95734
+ if (where) list.push(where);
95735
+ out2.set(s, list);
95736
+ }
95737
+ }
95738
+ return out2;
95739
+ }
95473
95740
 
95474
95741
  // src/multi.ts
95475
95742
  init_checkout_root();
@@ -95527,6 +95794,14 @@ function pickOwnerRoot(roots, absPath) {
95527
95794
  }
95528
95795
  return best;
95529
95796
  }
95797
+ function withAdditionalContext(response, hookEventName, extra) {
95798
+ const out2 = response["hookSpecificOutput"] ?? {};
95799
+ const prior = typeof out2.additionalContext === "string" && out2.additionalContext ? `${out2.additionalContext}
95800
+
95801
+ ` : "";
95802
+ const { ok: _ok, event: _event, tool: _tool, recognizedFile: _file2, ...rest2 } = response;
95803
+ return { ...rest2, hookSpecificOutput: { ...out2, hookEventName, additionalContext: `${prior}${extra}` } };
95804
+ }
95530
95805
  function ownerOf(records, absPath) {
95531
95806
  const root = pickOwnerRoot(records.map((r) => r.root), absPath);
95532
95807
  return root == null ? null : records.find((r) => r.root === root) ?? null;
@@ -95933,6 +96208,8 @@ async function startMultiDaemon(opts = {}) {
95933
96208
  const app = new Hono2();
95934
96209
  const enqueueWakeIndex = serialQueue();
95935
96210
  const attaching = /* @__PURE__ */ new Map();
96211
+ let afterAttach = () => {
96212
+ };
95936
96213
  const attachWorkspace = (root) => {
95937
96214
  const norm = normPath(root);
95938
96215
  const pending = attaching.get(norm);
@@ -95982,6 +96259,7 @@ async function startMultiDaemon(opts = {}) {
95982
96259
  });
95983
96260
  }
95984
96261
  console.log(`[errata] hot-registered ${rec.entry.name} (${rec.id}) \u2014 now watching live`);
96262
+ afterAttach(rec);
95985
96263
  return { attached: true, id: rec.id };
95986
96264
  };
95987
96265
  app.post("/api/attach", async (c) => {
@@ -96106,6 +96384,105 @@ async function startMultiDaemon(opts = {}) {
96106
96384
  })
96107
96385
  );
96108
96386
  const sessionAffinity = new SessionAffinity();
96387
+ const sessionFocus = new SessionFocus();
96388
+ const pendingSwitch = /* @__PURE__ */ new Map();
96389
+ const turnHome = /* @__PURE__ */ new Map();
96390
+ const registryRoots = () => listWorkspaceEntries().map((e) => e.path);
96391
+ const rootOfPath = (p, roots) => p ? ownerOf(records, p)?.root ?? pickOwnerRoot((roots ?? registryRoots)(), p) : null;
96392
+ const workspaceName = (root) => root ? listWorkspaceEntries().find((e) => e.path === root)?.name ?? basename10(root) : "its working directory";
96393
+ const recordFor = async (root) => {
96394
+ const running = records.find((r) => r.root === root);
96395
+ if (running) return running;
96396
+ return (await attachWorkspace(root)).attached ? records.find((r) => r.root === root) ?? null : null;
96397
+ };
96398
+ const focusNotice = (change, cwdRoot) => {
96399
+ const to = workspaceName(change.to);
96400
+ const from = workspaceName(change.from);
96401
+ const where = `Captures from here on go to ${to}'s graph, and the block below is ${to}'s context: it replaces any earlier errata block.`;
96402
+ if (change.reason === "pinned") return `errata: this session is now pinned to ${to} (\`errata focus\`). ${where} \`errata focus off\` returns it to ${workspaceName(cwdRoot)}.`;
96403
+ if (change.reason === "unpinned") return `errata: focus cleared; this session follows its working directory, ${to}, again. ${where}`;
96404
+ return `errata: this session's work moved from ${from} to ${to}: ${FOCUS_QUORUM} of the last ${FOCUS_WINDOW} paths your tools touched are in ${to}. ${where} Wrong workspace? Run \`errata focus ${from === "its working directory" ? "off" : from}\`.`;
96405
+ };
96406
+ const noteFocus = (sid, change) => {
96407
+ if (!change) return;
96408
+ pendingSwitch.set(sid, change);
96409
+ console.log(`[errata] session ${sid.slice(0, 8)} focus ${workspaceName(change.from)} \u2192 ${workspaceName(change.to)} (${change.reason})`);
96410
+ };
96411
+ const observeFocus = (sid, body2) => {
96412
+ const event = String(body2["hook_event_name"] ?? "");
96413
+ const cwd = typeof body2["cwd"] === "string" ? msysToWindowsPath(body2["cwd"]) : void 0;
96414
+ const cwdRoot = rootOfPath(cwd);
96415
+ const input = body2["tool_input"] ?? {};
96416
+ if (event === "PreToolUse") {
96417
+ const pin = focusCommandIn(input["command"]);
96418
+ if (!pin) return;
96419
+ if (pin.ref === null) return noteFocus(sid, sessionFocus.unpin(sid, cwdRoot));
96420
+ const entry = resolveWorkspaceRef(pin.ref, listWorkspaceEntries(), pickOwnerRoot);
96421
+ if (entry) noteFocus(sid, sessionFocus.pin(sid, entry.path, cwdRoot));
96422
+ return;
96423
+ }
96424
+ if (event !== "PostToolUse" && event !== "PostToolUseFailure") return;
96425
+ let registered = null;
96426
+ const roots = () => registered ??= registryRoots();
96427
+ const located = hookPathCandidates(body2).filter((p) => p !== cwd).map((p) => rootOfPath(p, roots)).find((r) => r != null);
96428
+ if (located) noteFocus(sid, sessionFocus.observe(sid, located, cwdRoot));
96429
+ else if (cwdRoot && (typeof input["command"] === "string" || ["Grep", "Glob", "LS"].includes(String(body2["tool_name"])))) {
96430
+ sessionFocus.ranIn(sid, cwdRoot);
96431
+ }
96432
+ };
96433
+ const repositoryOf = (root) => {
96434
+ const gitRoot = nearestGitRoot(root);
96435
+ return normPath((gitRoot ? mainCheckoutOfWorktree(gitRoot) : null) ?? root);
96436
+ };
96437
+ const evidenceWorkspace = (home) => {
96438
+ let registered = null;
96439
+ const known = () => registered ??= registryRoots();
96440
+ return (p) => {
96441
+ if (p === home) return p;
96442
+ const direct = rootOfPath(p, known);
96443
+ if (direct) return direct;
96444
+ const gitRoot = nearestGitRoot(p);
96445
+ const main2 = gitRoot ? mainCheckoutOfWorktree(gitRoot) : null;
96446
+ return main2 ? rootOfPath(main2, known) : null;
96447
+ };
96448
+ };
96449
+ const rehomeMisfiled = async (sid, from, to, why) => {
96450
+ if (repositoryOf(from.root) === repositoryOf(to.root)) return;
96451
+ await from.engine.settleTurns(sid);
96452
+ const workspaceOf = evidenceWorkspace(from.root);
96453
+ if ((captureWorkPaths(from.engine.store, from.root).get(sid) ?? []).some((p) => workspaceOf(p) === from.root)) return;
96454
+ const report = rehomeSessionNodes(from.engine.store, to.engine.store, [sid], { apply: true });
96455
+ if (report.moved.length + report.copied.length === 0) return;
96456
+ from.engine.markContextDirty();
96457
+ to.engine.markContextDirty();
96458
+ console.log(
96459
+ `[errata] session ${sid.slice(0, 8)} captures ${from.entry.name} \u2192 ${to.entry.name} (${why}): ${report.moved.length} moved, ${report.copied.length} copied, ${report.edgesDropped} code edges dropped` + (report.cloudContributed.length > 0 ? `, ${report.cloudContributed.length} already in the cloud under ${from.entry.name}` : "")
96460
+ );
96461
+ };
96462
+ const handOffTurns = (sid, owner, cwd) => {
96463
+ const mine = owner.engine.turnCursor(sid);
96464
+ let fromRoot = mine?.movedTo ?? turnHome.get(sid) ?? null;
96465
+ if (!fromRoot && !mine) fromRoot = rootOfPath(cwd);
96466
+ if (fromRoot && fromRoot !== owner.root) {
96467
+ const from = records.find((r) => r.root === fromRoot);
96468
+ const configDir = workspacePaths(fromRoot).configDir;
96469
+ const cursor = from ? from.engine.turnCursor(sid) : turnCursorOnDisk(configDir, sid);
96470
+ if (from) from.engine.releaseTurnCursor(sid, owner.root);
96471
+ else releaseTurnCursorOnDisk(configDir, sid, owner.root);
96472
+ owner.engine.adoptTurnCursor(sid, cursor?.movedTo ? null : cursor);
96473
+ console.log(`[errata] session ${sid.slice(0, 8)} turns: ${workspaceName(fromRoot)} \u2192 ${owner.entry.name}`);
96474
+ if (from && sessionFocus.focusFor(sid) === owner.root && sessionFocus.reached(sid, fromRoot) === false) {
96475
+ void rehomeMisfiled(sid, from, owner, "its tools never worked there").catch(
96476
+ (err2) => console.warn(`[errata] rehome of session ${sid.slice(0, 8)} failed: ${err2 instanceof Error ? err2.message : err2}`)
96477
+ );
96478
+ }
96479
+ } else if (mine?.movedTo) {
96480
+ owner.engine.adoptTurnCursor(sid, null);
96481
+ }
96482
+ turnHome.delete(sid);
96483
+ turnHome.set(sid, owner.root);
96484
+ if (turnHome.size > 500) turnHome.delete(turnHome.keys().next().value);
96485
+ };
96109
96486
  const forward = async (c, pathInBody, opts2 = {}) => {
96110
96487
  const raw2 = await c.req.text();
96111
96488
  let body2 = {};
@@ -96115,9 +96492,13 @@ async function startMultiDaemon(opts = {}) {
96115
96492
  return c.json({ error: "bad-json" }, 400);
96116
96493
  }
96117
96494
  lastHook = { at: Date.now(), event: typeof body2["hook_event_name"] === "string" ? body2["hook_event_name"] : c.req.path };
96495
+ const sid = typeof body2["session_id"] === "string" ? body2["session_id"] : "";
96496
+ if (sid && opts2.affinity === "note") observeFocus(sid, body2);
96497
+ const focusRoot = sid && opts2.affinity === "follow" ? sessionFocus.focusFor(sid) : null;
96118
96498
  const primary = pathInBody(body2);
96119
96499
  const others = (opts2.extraPaths?.(body2) ?? []).filter((p) => p !== primary);
96120
- let owner = ownerOf(records, primary);
96500
+ let owner = focusRoot ? await recordFor(focusRoot) : null;
96501
+ if (!owner) owner = ownerOf(records, primary);
96121
96502
  for (const p of others) {
96122
96503
  if (owner) break;
96123
96504
  owner = ownerOf(records, p);
@@ -96136,8 +96517,8 @@ async function startMultiDaemon(opts = {}) {
96136
96517
  }
96137
96518
  }
96138
96519
  if (!owner && others.length > 0) {
96139
- const registryRoots = listWorkspaceEntries().map((e) => e.path);
96140
- const registered = others.map((p) => pickOwnerRoot(registryRoots, p)).find((r) => r != null);
96520
+ const registryRoots2 = listWorkspaceEntries().map((e) => e.path);
96521
+ const registered = others.map((p) => pickOwnerRoot(registryRoots2, p)).find((r) => r != null);
96141
96522
  if (registered && (await attachWorkspace(registered)).attached) {
96142
96523
  owner = records.find((r) => r.root === registered) ?? null;
96143
96524
  if (owner) console.log(`[errata] woke workspace ${owner.entry.name}`);
@@ -96166,32 +96547,109 @@ async function startMultiDaemon(opts = {}) {
96166
96547
  if (opts2.affinity === "note") sessionAffinity.note(body2["session_id"], owner.root);
96167
96548
  owner.lastHookAt = Date.now();
96168
96549
  const sub = c.req.path;
96550
+ const cwd = typeof body2["cwd"] === "string" ? msysToWindowsPath(body2["cwd"]) : void 0;
96551
+ if (sid && (sub === "/api/turn" || sub === "/api/session-end")) handOffTurns(sid, owner, cwd);
96552
+ const subagentHook = typeof body2["agent_id"] === "string" && body2["agent_id"] !== "";
96553
+ const pending = sid && !subagentHook ? pendingSwitch.get(sid) : void 0;
96554
+ let raw22 = raw2;
96555
+ if (pending && sub === "/api/context-inject" && pending.to === owner.root) {
96556
+ raw22 = JSON.stringify({ ...body2, [FOCUS_NOTICE_FIELD]: focusNotice(pending, rootOfPath(cwd)) });
96557
+ pendingSwitch.delete(sid);
96558
+ }
96169
96559
  const tag = (v) => typeof v === "string" ? ` ${v.slice(0, 40)}` : "";
96170
96560
  const done = markPass(`${sub}:${owner.entry.name}${tag(body2["hook_event_name"])}${tag(body2["tool_name"])}`);
96561
+ const post = async (target, path3, payload) => await target.webApp.fetch(
96562
+ new Request(`http://local${path3}`, {
96563
+ method: "POST",
96564
+ // The origin header (harness/fixture quarantine, session-registry.ts)
96565
+ // is the one caller header the sub-app needs; nothing else crosses.
96566
+ headers: {
96567
+ "content-type": "application/json",
96568
+ ...c.req.header(ORIGIN_HEADER) ? { [ORIGIN_HEADER]: c.req.header(ORIGIN_HEADER) } : {}
96569
+ },
96570
+ body: payload
96571
+ })
96572
+ );
96573
+ const withFocusHeader = (res) => {
96574
+ const focusedRoot = sid ? sessionFocus.focusFor(sid) : null;
96575
+ if (!focusedRoot) return res;
96576
+ const headers = new Headers(res.headers);
96577
+ headers.set(FOCUS_ROOT_HEADER, focusedRoot);
96578
+ return new Response(res.body, { status: res.status, headers });
96579
+ };
96171
96580
  try {
96172
- return await owner.webApp.fetch(
96173
- new Request(`http://local${sub}`, {
96174
- method: "POST",
96175
- // The origin header (harness/fixture quarantine, session-registry.ts)
96176
- // is the one caller header the sub-app needs; nothing else crosses.
96177
- headers: {
96178
- "content-type": "application/json",
96179
- ...c.req.header(ORIGIN_HEADER) ? { [ORIGIN_HEADER]: c.req.header(ORIGIN_HEADER) } : {}
96180
- },
96181
- body: raw2
96182
- })
96183
- );
96581
+ const res = await post(owner, sub, raw22);
96582
+ if (sub === "/api/session-end" && sid) {
96583
+ sessionFocus.forget(sid);
96584
+ pendingSwitch.delete(sid);
96585
+ }
96586
+ const hookEvent = String(body2["hook_event_name"] ?? "");
96587
+ if (pending && pendingSwitch.has(sid) && sub === "/api/hook" && ["PreToolUse", "PostToolUse", "PostToolUseFailure"].includes(hookEvent)) {
96588
+ const target = await recordFor(pending.to);
96589
+ if (target) {
96590
+ pendingSwitch.delete(sid);
96591
+ const inject = await post(target, "/api/context-inject", JSON.stringify({
96592
+ session_id: sid,
96593
+ hook_event_name: hookEvent,
96594
+ cwd: target.root,
96595
+ ...typeof body2["transcript_path"] === "string" ? { transcript_path: body2["transcript_path"] } : {},
96596
+ [FOCUS_NOTICE_FIELD]: focusNotice(pending, rootOfPath(cwd))
96597
+ }));
96598
+ const extra = (await inject.json().catch(() => ({}))).hookSpecificOutput?.additionalContext;
96599
+ if (extra) {
96600
+ const base = res.status === 204 ? {} : await res.json().catch(() => ({}));
96601
+ return withFocusHeader(c.json(withAdditionalContext(base, hookEvent, extra)));
96602
+ }
96603
+ }
96604
+ }
96605
+ return withFocusHeader(res);
96184
96606
  } finally {
96185
96607
  done();
96186
96608
  }
96187
96609
  };
96610
+ const MISFILED_SWEEP_DELAY_MS = 2 * 6e4;
96611
+ const sweepMisfiled = async (rec) => {
96612
+ const checkedPath = join47(workspacePaths(rec.root).configDir, "rehome-checked.json");
96613
+ let checked;
96614
+ try {
96615
+ checked = new Set(JSON.parse(readFileSync40(checkedPath, "utf8")));
96616
+ } catch {
96617
+ checked = /* @__PURE__ */ new Set();
96618
+ }
96619
+ const workspaceOf = evidenceWorkspace(rec.root);
96620
+ let changed = false;
96621
+ for (const [sid, paths] of captureWorkPaths(rec.engine.store, rec.root)) {
96622
+ if (stopping) break;
96623
+ if (checked.has(sid) || turnHome.has(sid) || sessionFocus.reached(sid, rec.root) !== void 0) continue;
96624
+ checked.add(sid);
96625
+ changed = true;
96626
+ const target = misfiledTarget(paths.map(workspaceOf).filter((r) => r != null), rec.root);
96627
+ const to = target ? await recordFor(target) : null;
96628
+ if (to) await rehomeMisfiled(sid, rec, to, "its captures were raised in its files");
96629
+ await new Promise((r) => setImmediate(r));
96630
+ }
96631
+ if (changed) writeFileSync29(checkedPath, JSON.stringify([...checked]), "utf8");
96632
+ };
96633
+ afterAttach = (rec) => {
96634
+ const t = setTimeout(() => {
96635
+ void sweepMisfiled(rec).catch(
96636
+ (err2) => console.warn(`[errata] misfiled-session check of ${rec.entry.name} failed: ${err2 instanceof Error ? err2.message : err2}`)
96637
+ );
96638
+ }, MISFILED_SWEEP_DELAY_MS);
96639
+ t.unref?.();
96640
+ };
96641
+ for (const rec of records) afterAttach(rec);
96642
+ app.get(
96643
+ "/api/focus",
96644
+ (c) => c.json({ sessions: sessionFocus.entries().map((f) => ({ ...f, name: workspaceName(f.root) })) })
96645
+ );
96188
96646
  app.post(
96189
96647
  "/api/hook",
96190
96648
  (c) => forward(c, (b) => {
96191
96649
  const input = b["tool_input"] ?? {};
96192
96650
  const fp = input["file_path"];
96193
96651
  const cwd = b["cwd"];
96194
- return (typeof fp === "string" ? fp : void 0) ?? (typeof cwd === "string" ? cwd : void 0);
96652
+ return (typeof fp === "string" ? fp : void 0) ?? leadingCdTarget(input["command"]) ?? (typeof cwd === "string" ? cwd : void 0);
96195
96653
  }, { extraPaths: (b) => hookPathCandidates(b), affinity: "note" })
96196
96654
  );
96197
96655
  app.post(
@@ -98368,6 +98826,9 @@ try {
98368
98826
  // a changed snapshot can go out as only what changed (the daemon renders it).
98369
98827
  const bodyPath = statePath && statePath.replace(/\.json$/, '.body.md');
98370
98828
  let emittedBody = null;
98829
+ // Set when the daemon routes this session to another workspace (errata focus):
98830
+ // that workspace's block is the current one.
98831
+ let focusRoot = null;
98371
98832
  const post = async (endpoint, relay = false) => {
98372
98833
  try {
98373
98834
  const response = await fetch('http://127.0.0.1:' + port + '/api/' + endpoint, {
@@ -98375,6 +98836,8 @@ try {
98375
98836
  body: JSON.stringify(payload), signal: AbortSignal.timeout(2000),
98376
98837
  });
98377
98838
  if (!response.ok) return false;
98839
+ const focused = response.headers.get('x-errata-focus-root');
98840
+ if (focused) focusRoot = focused;
98378
98841
  if (!relay) return true;
98379
98842
  const result = await response.json();
98380
98843
  const context = result.hookSpecificOutput?.additionalContext;
@@ -98395,17 +98858,20 @@ try {
98395
98858
  }
98396
98859
  if (workspaceRoot && ['SessionStart', 'UserPromptSubmit', 'PostToolUse'].includes(event)) {
98397
98860
  let body = '';
98398
- try { body = boundedRead(join(workspaceRoot, '.errata', 'context.md'), 128 * 1024).trim(); } catch {}
98861
+ const blockRoot = focusRoot || workspaceRoot;
98862
+ try { body = boundedRead(join(blockRoot, '.errata', 'context.md'), 128 * 1024).trim(); } catch {}
98399
98863
  if (body) {
98400
98864
  const digest = hash(body.replace(/\r\n?/g, '\n')).slice(0, 16);
98865
+ // A different workspace's block than the one last sent: a delta between them is noise.
98866
+ const switched = (previous.root || workspaceRoot) !== blockRoot;
98401
98867
  // Receipt is local to this graph + session, survives daemon restarts, and
98402
98868
  // records stdout emission, not a server's intention to send a response.
98403
- if (event === 'SessionStart' || previous.hash !== digest) {
98869
+ if (event === 'SessionStart' || switched || previous.hash !== digest) {
98404
98870
  const normalized = body.replace(/\r\n?/g, '\n');
98405
98871
  // Every SessionStart (startup, resume, compact, clear) sends the whole
98406
98872
  // block: the window may no longer hold the last one.
98407
98873
  let delta = null;
98408
- if (event !== 'SessionStart' && previous.hash && bodyPath) {
98874
+ if (event !== 'SessionStart' && !switched && previous.hash && bodyPath) {
98409
98875
  let prior = '';
98410
98876
  try { prior = boundedRead(bodyPath, 512 * 1024); } catch {}
98411
98877
  if (prior && hash(prior).slice(0, 16) === previous.hash) {
@@ -98423,7 +98889,7 @@ try {
98423
98889
  // Only bookkeeping moved: nothing to send, but this snapshot is now what the window holds.
98424
98890
  try {
98425
98891
  mkdirSync(stateDir, { recursive: true });
98426
- writeFileSync(statePath, JSON.stringify({ hash: digest, at: Date.now() }), { mode: 0o600 });
98892
+ writeFileSync(statePath, JSON.stringify({ hash: digest, at: Date.now(), root: blockRoot }), { mode: 0o600 });
98427
98893
  writeFileSync(bodyPath, normalized, { mode: 0o600 });
98428
98894
  } catch { /* the next change just goes whole */ }
98429
98895
  } else {
@@ -98432,11 +98898,11 @@ try {
98432
98898
  : 'Errata loaded the maintained local workspace context. This block REPLACES any earlier errata context block in this conversation. It is the latest saved snapshot; the daemon maintains it as work proceeds.\n\n' + body);
98433
98899
  if (!/^(0|off|false|no)$/i.test((process.env.ERRATA_RECALL_CARD || '').trim())) {
98434
98900
  try {
98435
- const card = JSON.parse(boundedRead(join(workspaceRoot, '.errata', 'context-card.json'), 128 * 1024));
98901
+ const card = JSON.parse(boundedRead(join(blockRoot, '.errata', 'context-card.json'), 128 * 1024));
98436
98902
  if (card.hash === digest && typeof card.line === 'string' && card.line) messages.push(card.line);
98437
98903
  } catch { /* Never show a card for a different snapshot. */ }
98438
98904
  }
98439
- receipt = { hash: digest, at: Date.now() };
98905
+ receipt = { hash: digest, at: Date.now(), root: blockRoot };
98440
98906
  emittedBody = normalized;
98441
98907
  }
98442
98908
  }
@@ -99962,6 +100428,10 @@ async function main() {
99962
100428
  return cmdUpdate(rest);
99963
100429
  case "projects":
99964
100430
  return cmdProjects();
100431
+ case "focus":
100432
+ return cmdFocus(rest);
100433
+ case "rehome":
100434
+ return cmdRehome(rest);
99965
100435
  case "dash":
99966
100436
  return cmdDash(rest);
99967
100437
  case "notify-test":
@@ -99985,6 +100455,14 @@ function printHelp() {
99985
100455
  Commands:
99986
100456
  init Auto-detect this workspace's profile, write .errata/workspace.json
99987
100457
  projects List every initialized project + whether its daemon is running
100458
+ focus <workspace|off>
100459
+ Run from an agent's shell tool: that session's captures and
100460
+ context follow the named workspace (name or path). Sessions
100461
+ also follow on their own once most of the paths their tools
100462
+ touch are in another workspace; this pins it.
100463
+ rehome --session <id> --from <ws> --to <ws> [--apply]
100464
+ Move what a session captured into the workspace its work was
100465
+ about (a session started in the wrong repo). Dry run by default.
99988
100466
  dash [--port N] [--no-reindex] [--embed]
99989
100467
  One daemon watching ALL initialized projects, routing hooks
99990
100468
  to each project's graph (default port 7891). JSON endpoints
@@ -101102,9 +101580,11 @@ async function cmdLink(args2) {
101102
101580
  saveConfig(synced.config);
101103
101581
  }
101104
101582
  }
101105
- if (!consents.mineHistory) console.log("history: not mined (consent off)");
101106
- else if (reported?.stopRequested) console.log("history: a stop request is standing in the console; the daemon honours it");
101107
- else console.log(`history: consents recorded${consents.contributeHistory ? "" : " (mine only)"} \xB7 the daemon walks the repo on its next pass`);
101583
+ if (historyIngestEnabled()) {
101584
+ if (!consents.mineHistory) console.log("history: not mined (consent off)");
101585
+ else if (reported?.stopRequested) console.log("history: a stop request is standing in the console; the daemon honours it");
101586
+ else console.log(`history: consents recorded${consents.contributeHistory ? "" : " (mine only)"} \xB7 the daemon walks the repo on its next pass`);
101587
+ }
101108
101588
  return;
101109
101589
  } catch (err2) {
101110
101590
  const message2 = err2 instanceof Error ? err2.message : String(err2);
@@ -103046,6 +103526,82 @@ async function cmdProjects() {
103046
103526
  console.log(` ${w.path}`);
103047
103527
  }
103048
103528
  }
103529
+ async function cmdFocus(args2) {
103530
+ const entries = listWorkspaceEntries();
103531
+ const ref = args2.find((a) => !a.startsWith("-"));
103532
+ const clear = args2.includes("--clear") || ref === "off" || ref === "clear";
103533
+ const port = resolveHookPort();
103534
+ const focused = async () => {
103535
+ try {
103536
+ const r = await fetch(`http://127.0.0.1:${port}/api/focus`, { signal: AbortSignal.timeout(2e3) });
103537
+ return r.ok ? (await r.json()).sessions : [];
103538
+ } catch {
103539
+ return [];
103540
+ }
103541
+ };
103542
+ if (!ref && !clear) {
103543
+ const now = await focused();
103544
+ console.log("usage: errata focus <workspace name or path> | errata focus off");
103545
+ console.log("Run it from an agent's shell tool: the session that runs it follows that workspace.\n");
103546
+ if (now.length === 0) console.log("No session is focused away from its working directory.");
103547
+ for (const f of now) console.log(` ${f.sessionId.slice(0, 8)} ${f.pinned ? "pinned to" : "following"} ${f.name} (${f.root})`);
103548
+ return;
103549
+ }
103550
+ if (clear) {
103551
+ console.log("focus cleared for the agent session that ran this: it follows its working directory again.");
103552
+ return;
103553
+ }
103554
+ const entry = resolveWorkspaceRef(ref, entries, pickOwnerRoot);
103555
+ if (!entry) {
103556
+ const same = entries.filter((e) => e.name.toLowerCase() === ref.toLowerCase());
103557
+ console.error(same.length > 1 ? `"${ref}" names ${same.length} workspaces; use a path instead:
103558
+ ${same.map((e) => ` ${e.path}`).join("\n")}` : `no workspace named or containing "${ref}". Known workspaces:
103559
+ ${entries.map((e) => ` ${e.name.padEnd(24)} ${e.path}`).join("\n")}`);
103560
+ process.exit(2);
103561
+ }
103562
+ const pinned = (await focused()).filter((f) => f.pinned && f.root === entry.path && Date.now() - f.at < 3e4);
103563
+ if (pinned.length === 0) {
103564
+ console.error(`${entry.name} (${entry.path}) is a workspace, but no agent session was pinned to it.`);
103565
+ console.error("Run this from an agent's shell tool, with the errata daemon running: the daemon pins the session whose hook reports the command.");
103566
+ process.exit(1);
103567
+ }
103568
+ console.log(`focus: ${entry.name} (${entry.path}), pinned for session ${pinned.map((f) => f.sessionId.slice(0, 8)).join(", ")}.`);
103569
+ console.log("Captures and context now come from this workspace. `errata focus off` returns the session to its working directory.");
103570
+ }
103571
+ async function cmdRehome(args2) {
103572
+ const values = (flag) => args2.flatMap((a, i2) => a === flag && args2[i2 + 1] ? [args2[i2 + 1]] : []);
103573
+ const sessions = values("--session");
103574
+ const [fromRef] = values("--from");
103575
+ const [toRef] = values("--to");
103576
+ const apply = args2.includes("--apply");
103577
+ if (sessions.length === 0 || !fromRef || !toRef) {
103578
+ console.error("usage: errata rehome --session <id> [--session <id> \u2026] --from <workspace> --to <workspace> [--apply]");
103579
+ process.exit(2);
103580
+ }
103581
+ const entries = listWorkspaceEntries();
103582
+ const from = resolveWorkspaceRef(fromRef, entries, pickOwnerRoot);
103583
+ const to = resolveWorkspaceRef(toRef, entries, pickOwnerRoot);
103584
+ if (!from || !to || from.path === to.path) {
103585
+ console.error(`--from and --to must name two different workspaces (got ${from?.name ?? fromRef} \u2192 ${to?.name ?? toRef}).`);
103586
+ process.exit(2);
103587
+ }
103588
+ const { openGraphStore: openGraphStore2 } = await Promise.resolve().then(() => (init_src5(), src_exports2));
103589
+ const src = openGraphStore2({ path: workspacePaths(from.path).castalia });
103590
+ const dst = openGraphStore2({ path: workspacePaths(to.path).castalia });
103591
+ try {
103592
+ const r = rehomeSessionNodes(src, dst, sessions, { apply });
103593
+ const verb = apply ? "" : "would ";
103594
+ console.log(`${from.name} \u2192 ${to.name}${apply ? "" : " (dry run; --apply to write)"}`);
103595
+ console.log(` ${verb}move ${r.moved.length} node(s) (closed in ${from.name}), ${verb}copy ${r.copied.length} shared with other sessions`);
103596
+ console.log(` ${verb}carry ${r.edgesCarried} edge(s), ${verb}add ${r.vocabulary.length} vocabulary node(s) to ${to.name}; ${r.edgesDropped} edge(s) into ${from.name}'s code dropped`);
103597
+ if (r.cloudContributed.length > 0) {
103598
+ console.log(` ${r.cloudContributed.length} of them were already contributed to the cloud under ${from.name}'s project; those cloud copies are not moved.`);
103599
+ }
103600
+ } finally {
103601
+ src.close();
103602
+ dst.close();
103603
+ }
103604
+ }
103049
103605
  async function cmdNotifications(state) {
103050
103606
  const s = state.toLowerCase();
103051
103607
  if (s !== "on" && s !== "off") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inerrata-corporation/errata",
3
- "version": "2.0.23-dev.4779",
3
+ "version": "2.0.23-dev.4803",
4
4
  "description": "errata - local-first observation engine for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "errata-context",
3
- "version": "2.0.23-dev.4779",
3
+ "version": "2.0.23-dev.4803",
4
4
  "description": "A minimal 3D view of loaded Errata priors and recent graph bursts.",
5
5
  "author": {
6
6
  "name": "inErrata"
@@ -1,4 +1,4 @@
1
- 8c2ed59e34694f0fbec381b67731aae53d87a289c92222a279d038f65e3bcad0 .codex-plugin/plugin.json
1
+ 08211bb4b884703471ec49f813c41d354603948bade76fd5acd9627748d4d7a3 .codex-plugin/plugin.json
2
2
  a7bebae9fb01c838d6c75fc784129e28be571233fa66ff5ee8039b574f35ccf7 assets/icon.png
3
3
  fc38b64e4df1e0b72c35764bef6c4c05430d97c9009ad0ecc8a9e75b4deab232 configure-local.mjs
4
4
  73dc6028b3b070257c7c31f252d11e55a9e096f4aed0f187f10cb21716e1e6d7 dist/client.js