@mrciphersmith/keryx 0.2.107 → 0.2.108

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.
Files changed (2) hide show
  1. package/dist/cli.js +308 -65
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -48579,7 +48579,7 @@ import path47 from "path";
48579
48579
  // package.json
48580
48580
  var package_default = {
48581
48581
  name: "@mrciphersmith/keryx",
48582
- version: "0.2.107",
48582
+ version: "0.2.108",
48583
48583
  description: "Version-controlled project context for AI coding agents: code graph, architecture wiki, project memory, relevant tests, quality signals, and task flows.",
48584
48584
  private: false,
48585
48585
  publishConfig: {
@@ -80462,6 +80462,36 @@ var MAX_BACKGROUND_OUTPUT_BYTES = 2 * 1024 * 1024;
80462
80462
  var DEFAULT_INITIAL_BUFFER_MS = 500;
80463
80463
  var MAX_TRACKED_JOBS = 50;
80464
80464
  var TERMINATED_OUTPUT_TAIL_BYTES = 4000;
80465
+ var TASK_OUTPUT_HEAD_BYTES = 24000;
80466
+ var ENV_SHELL_IDLE_MS = "KERYX_SHELL_IDLE_MS";
80467
+ var ENV_SHELL_TIMEOUT_MS_FALLBACK = "KERYX_SHELL_TIMEOUT_MS";
80468
+ var DEFAULT_SHELL_IDLE_MS = 120000;
80469
+ var MIN_TASK_IDLE_TIMEOUT_MS = 1000;
80470
+ var MAX_TASK_IDLE_TIMEOUT_MS = 1800000;
80471
+ function parseEnvMs(raw) {
80472
+ if (raw === undefined || raw.trim().length === 0)
80473
+ return;
80474
+ const n = Number.parseInt(raw.trim(), 10);
80475
+ if (!Number.isFinite(n) || n < 0)
80476
+ return Number.NaN;
80477
+ return n;
80478
+ }
80479
+ function resolveShellIdleMs(env = process.env) {
80480
+ const idle = parseEnvMs(env[ENV_SHELL_IDLE_MS]);
80481
+ if (idle !== undefined) {
80482
+ return Number.isNaN(idle) ? DEFAULT_SHELL_IDLE_MS : idle;
80483
+ }
80484
+ const legacy = parseEnvMs(env[ENV_SHELL_TIMEOUT_MS_FALLBACK]);
80485
+ if (legacy !== undefined) {
80486
+ return Number.isNaN(legacy) ? DEFAULT_SHELL_IDLE_MS : legacy;
80487
+ }
80488
+ return DEFAULT_SHELL_IDLE_MS;
80489
+ }
80490
+ function clampTaskIdleTimeoutMs(ms) {
80491
+ if (!Number.isFinite(ms))
80492
+ return MIN_TASK_IDLE_TIMEOUT_MS;
80493
+ return Math.min(MAX_TASK_IDLE_TIMEOUT_MS, Math.max(MIN_TASK_IDLE_TIMEOUT_MS, Math.trunc(ms)));
80494
+ }
80465
80495
  function sleep(ms) {
80466
80496
  return new Promise((resolve) => setTimeout(resolve, ms));
80467
80497
  }
@@ -80539,11 +80569,34 @@ function createJobRegistry(options) {
80539
80569
  const initialBufferMs = options?.initialBufferMs ?? DEFAULT_INITIAL_BUFFER_MS;
80540
80570
  const onEvent = options?.onEvent;
80541
80571
  const maxTrackedJobs = options?.maxTrackedJobs ?? MAX_TRACKED_JOBS;
80572
+ const idleMs = options?.idleMs ?? resolveShellIdleMs();
80542
80573
  let nextId = 0;
80543
80574
  const jobs = new Map;
80544
80575
  function runningJobs() {
80545
80576
  return [...jobs.values()].filter((j) => j.info.status === "running");
80546
80577
  }
80578
+ function backgroundRunningJobs() {
80579
+ return runningJobs().filter((j) => j.info.phase === "background");
80580
+ }
80581
+ function clearIdleTimer(job) {
80582
+ if (job.idleTimer === undefined)
80583
+ return;
80584
+ clearTimeout(job.idleTimer);
80585
+ job.idleTimer = undefined;
80586
+ }
80587
+ function armIdleTimer(job) {
80588
+ clearIdleTimer(job);
80589
+ if (job.info.status !== "running")
80590
+ return;
80591
+ const timeout = job.info.idleTimeoutMs;
80592
+ if (timeout <= 0)
80593
+ return;
80594
+ const timer = setTimeout(() => {
80595
+ requestKill(job, "idle");
80596
+ }, timeout);
80597
+ timer.unref?.();
80598
+ job.idleTimer = timer;
80599
+ }
80547
80600
  function evictOldestTerminatedIfOverCap() {
80548
80601
  if (jobs.size <= maxTrackedJobs)
80549
80602
  return;
@@ -80564,20 +80617,32 @@ function createJobRegistry(options) {
80564
80617
  }
80565
80618
  function appendOutput(job, chunk, stream) {
80566
80619
  job.outputBuffer += chunk;
80620
+ if (job.outputHead.length < TASK_OUTPUT_HEAD_BYTES) {
80621
+ job.outputHead = (job.outputHead + chunk).slice(0, TASK_OUTPUT_HEAD_BYTES);
80622
+ job.info.outputHead = job.outputHead;
80623
+ }
80624
+ armIdleTimer(job);
80567
80625
  onEvent?.({ type: "output", jobId: job.info.jobId, chunk, stream });
80568
80626
  if (job.outputBuffer.length > MAX_BACKGROUND_OUTPUT_BYTES) {
80569
80627
  const dropped = job.outputBuffer.length - MAX_BACKGROUND_OUTPUT_BYTES;
80570
80628
  job.outputBuffer = job.outputBuffer.slice(dropped);
80571
80629
  job.readCursor = Math.max(0, job.readCursor - dropped);
80572
- if (!job.killRequested) {
80573
- job.killRequested = true;
80574
- terminateJob(job, killGraceMs);
80575
- }
80630
+ requestKill(job, "output-cap");
80576
80631
  }
80577
80632
  }
80633
+ function requestKill(job, reason) {
80634
+ if (job.info.status !== "running")
80635
+ return Promise.resolve();
80636
+ if (job.killRequested)
80637
+ return job.exitedPromise;
80638
+ job.killRequested = true;
80639
+ job.pendingKillReason = reason;
80640
+ return terminateJob(job, killGraceMs);
80641
+ }
80578
80642
  async function terminateJob(job, graceMs) {
80579
80643
  if (job.info.status !== "running")
80580
80644
  return;
80645
+ clearIdleTimer(job);
80581
80646
  job.killRequested = true;
80582
80647
  job.handle.kill("SIGTERM");
80583
80648
  const exited = await Promise.race([job.exitedPromise.then(() => true), sleep(graceMs).then(() => false)]);
@@ -80586,14 +80651,17 @@ function createJobRegistry(options) {
80586
80651
  }
80587
80652
  }
80588
80653
  return {
80589
- async start(command) {
80590
- const running = runningJobs();
80591
- if (running.length >= maxConcurrent) {
80592
- const names = running.map((j) => j.info.command).join(", ");
80593
- return {
80594
- ok: false,
80595
- error: `background job limit reached (${maxConcurrent} running: ${names}); wait for one to finish or kill it with shell_job_kill first`
80596
- };
80654
+ async start(command, opts) {
80655
+ const phase = opts?.phase ?? "background";
80656
+ if (phase === "background") {
80657
+ const running = backgroundRunningJobs();
80658
+ if (running.length >= maxConcurrent) {
80659
+ const names = running.map((j) => j.info.command).join(", ");
80660
+ return {
80661
+ ok: false,
80662
+ error: `background task limit reached (${maxConcurrent} running: ${names}); wait for one to finish or kill it with shell_job_kill first`
80663
+ };
80664
+ }
80597
80665
  }
80598
80666
  let handle;
80599
80667
  try {
@@ -80604,7 +80672,7 @@ function createJobRegistry(options) {
80604
80672
  error: `command failed to start: ${cause instanceof Error ? cause.message : String(cause)}`
80605
80673
  };
80606
80674
  }
80607
- const jobId = `job-${++nextId}-${handle.pid}`;
80675
+ const jobId = `task-${++nextId}-${handle.pid}`;
80608
80676
  let resolveExited;
80609
80677
  const exitedPromise = new Promise((resolve) => {
80610
80678
  resolveExited = resolve;
@@ -80615,26 +80683,46 @@ function createJobRegistry(options) {
80615
80683
  pid: handle.pid,
80616
80684
  command,
80617
80685
  status: "running",
80686
+ phase,
80687
+ idleTimeoutMs: opts?.idleTimeoutMs ?? idleMs,
80618
80688
  startedAt: nowIso3()
80619
80689
  },
80620
80690
  handle,
80621
80691
  outputBuffer: "",
80692
+ outputHead: "",
80622
80693
  readCursor: 0,
80623
80694
  exited: false,
80624
80695
  killRequested: false,
80696
+ phaseEmitted: false,
80625
80697
  resolveExited,
80626
80698
  exitedPromise
80627
80699
  };
80700
+ if (opts?.description !== undefined)
80701
+ job.info.description = opts.description;
80628
80702
  jobs.set(jobId, job);
80629
80703
  evictOldestTerminatedIfOverCap();
80630
- onEvent?.({ type: "start", jobId, pid: handle.pid, command, startedAt: job.info.startedAt });
80704
+ onEvent?.({
80705
+ type: "start",
80706
+ jobId,
80707
+ pid: handle.pid,
80708
+ command,
80709
+ startedAt: job.info.startedAt,
80710
+ ...opts?.description !== undefined ? { description: opts.description } : {}
80711
+ });
80712
+ if (phase === "background") {
80713
+ job.phaseEmitted = true;
80714
+ onEvent?.({ type: "phase", jobId, phase: "background" });
80715
+ }
80631
80716
  handle.onOutput((chunk, stream) => appendOutput(job, chunk, stream));
80632
80717
  handle.onExit((info) => {
80633
80718
  if (job.exited)
80634
80719
  return;
80635
80720
  job.exited = true;
80636
- if (job.info.status === "running") {
80637
- job.info.status = job.killRequested ? "killed" : "exited";
80721
+ clearIdleTimer(job);
80722
+ const terminal = job.killRequested ? "killed" : info.exitCode === 0 ? "completed" : "failed";
80723
+ job.info.status = terminal;
80724
+ if (job.killRequested) {
80725
+ job.info.killReason = job.pendingKillReason ?? "model";
80638
80726
  }
80639
80727
  job.info.exitCode = info.exitCode;
80640
80728
  job.info.endedAt = nowIso3();
@@ -80642,13 +80730,15 @@ function createJobRegistry(options) {
80642
80730
  onEvent?.({
80643
80731
  type: "exit",
80644
80732
  jobId,
80645
- status: job.info.status,
80733
+ status: terminal,
80734
+ ...job.info.killReason !== undefined ? { killReason: job.info.killReason } : {},
80646
80735
  exitCode: info.exitCode,
80647
80736
  endedAt: job.info.endedAt
80648
80737
  });
80649
80738
  shrinkTerminatedOutput(job);
80650
80739
  });
80651
- if (initialBufferMs > 0) {
80740
+ armIdleTimer(job);
80741
+ if (phase === "background" && initialBufferMs > 0) {
80652
80742
  await sleep(initialBufferMs);
80653
80743
  }
80654
80744
  const output = job.outputBuffer.slice(job.readCursor);
@@ -80670,7 +80760,41 @@ function createJobRegistry(options) {
80670
80760
  job.readCursor = job.outputBuffer.length;
80671
80761
  return { ok: true, output };
80672
80762
  },
80673
- async kill(jobId) {
80763
+ async waitForExit(jobId, ms) {
80764
+ const job = jobs.get(jobId);
80765
+ if (job === undefined)
80766
+ return "unknown";
80767
+ if (job.exited || job.info.status !== "running")
80768
+ return "exited";
80769
+ let timer;
80770
+ const timedOut = await Promise.race([
80771
+ job.exitedPromise.then(() => false),
80772
+ new Promise((resolve) => {
80773
+ timer = setTimeout(() => resolve(true), ms);
80774
+ timer.unref?.();
80775
+ })
80776
+ ]);
80777
+ if (timer !== undefined)
80778
+ clearTimeout(timer);
80779
+ return timedOut ? "timeout" : "exited";
80780
+ },
80781
+ promote(jobId) {
80782
+ const job = jobs.get(jobId);
80783
+ if (job === undefined) {
80784
+ return { ok: false, error: `unknown job_id: ${jobId}` };
80785
+ }
80786
+ job.info.phase = "background";
80787
+ if (!job.phaseEmitted) {
80788
+ job.phaseEmitted = true;
80789
+ onEvent?.({ type: "phase", jobId, phase: "background" });
80790
+ }
80791
+ const others = backgroundRunningJobs().filter((j) => j.info.jobId !== jobId);
80792
+ if (job.info.status === "running" && others.length >= maxConcurrent) {
80793
+ return { ok: true, overCap: others.map((j) => j.info.command).join(", ") };
80794
+ }
80795
+ return { ok: true };
80796
+ },
80797
+ async kill(jobId, reason = "model") {
80674
80798
  const job = jobs.get(jobId);
80675
80799
  if (job === undefined) {
80676
80800
  return { ok: false, error: `unknown job_id: ${jobId}` };
@@ -80678,11 +80802,11 @@ function createJobRegistry(options) {
80678
80802
  if (job.info.status !== "running") {
80679
80803
  return { ok: false, error: `job ${jobId} is not running (status: ${job.info.status})` };
80680
80804
  }
80681
- await terminateJob(job, killGraceMs);
80805
+ await requestKill(job, reason);
80682
80806
  return { ok: true };
80683
80807
  },
80684
80808
  async sweepAll() {
80685
- await Promise.all(runningJobs().map((job) => terminateJob(job, killGraceMs)));
80809
+ await Promise.all(runningJobs().map((job) => requestKill(job, "session-exit")));
80686
80810
  }
80687
80811
  };
80688
80812
  }
@@ -80690,7 +80814,7 @@ function shellJobOutputTool(registry) {
80690
80814
  return {
80691
80815
  definition: {
80692
80816
  name: "shell_job_output",
80693
- description: "Return output produced by a background job (started via shell_exec with background:true) SINCE the " + "previous call for that job_id \u2014 never the full transcript again. Input: { job_id: string }. Poll this " + "instead of re-running shell_exec to check on a long-running job.",
80817
+ description: "Return output produced by a shell task SINCE the previous call for that id \u2014 never the full transcript " + "again. Input: { job_id: string } \u2014 pass the task_id shell_exec returned for a command that outlived its " + "yield (or a background:true job). Poll this instead of re-running shell_exec to check on a long command.",
80694
80818
  inputSchema: {
80695
80819
  type: "object",
80696
80820
  properties: { job_id: { type: "string" } },
@@ -80716,7 +80840,7 @@ function shellJobKillTool(registry) {
80716
80840
  return {
80717
80841
  definition: {
80718
80842
  name: "shell_job_kill",
80719
- description: "Kill a background job started via shell_exec with background:true (its entire process group, including " + "any descendant it backgrounded). Input: { job_id: string }. Only jobs in this session's own registry can " + "be targeted.",
80843
+ description: "Stop a running shell task \u2014 its entire process group, including any descendant it backgrounded. Input: " + "{ job_id: string } \u2014 the task_id shell_exec returned for a command still running after its yield (or a " + "background:true job). Only tasks in this session's own registry can be targeted.",
80720
80844
  inputSchema: {
80721
80845
  type: "object",
80722
80846
  properties: { job_id: { type: "string" } },
@@ -80921,6 +81045,37 @@ function resolveShellTimeoutMs(env = process.env) {
80921
81045
  }
80922
81046
  return n;
80923
81047
  }
81048
+ var DEFAULT_SHELL_YIELD_MS = 1e4;
81049
+ var ENV_SHELL_YIELD_MS = "KERYX_SHELL_YIELD_MS";
81050
+ function resolveShellYieldMs(env = process.env) {
81051
+ const raw = env[ENV_SHELL_YIELD_MS];
81052
+ if (raw === undefined || raw.trim().length === 0) {
81053
+ return DEFAULT_SHELL_YIELD_MS;
81054
+ }
81055
+ const n = Number.parseInt(raw.trim(), 10);
81056
+ if (!Number.isFinite(n) || n < 0) {
81057
+ return DEFAULT_SHELL_YIELD_MS;
81058
+ }
81059
+ return n;
81060
+ }
81061
+ function boundOutput2(text) {
81062
+ return text.length > MAX_OUTPUT_BYTES3 ? `${text.slice(0, MAX_OUTPUT_BYTES3)}
81063
+ \u2026(truncated)` : text;
81064
+ }
81065
+ function taskHandleResult(args) {
81066
+ return {
81067
+ output: JSON.stringify({
81068
+ task_id: args.taskId,
81069
+ job_id: args.taskId,
81070
+ pid: args.pid,
81071
+ status: "running",
81072
+ output: boundOutput2(args.output),
81073
+ notice: args.notice,
81074
+ ...args.overCap !== undefined ? { over_cap: args.overCap } : {}
81075
+ }),
81076
+ isError: false
81077
+ };
81078
+ }
80924
81079
  function makeCommandRunner(root) {
80925
81080
  return async (command) => {
80926
81081
  let netClose = async () => {};
@@ -81014,14 +81169,19 @@ ${notice}` : notice,
81014
81169
  }
81015
81170
  };
81016
81171
  }
81017
- function shellExecTool(root, run = makeCommandRunner(root), jobRegistry) {
81172
+ function shellExecTool(root, run = makeCommandRunner(root), jobRegistry, options) {
81018
81173
  return {
81019
81174
  definition: {
81020
81175
  name: "shell_exec",
81021
- description: "Run a shell command in the project root (e.g. `git status`, `bun test`). Requires the user's approval " + "before it runs. Input: { command: string, background?: boolean }. Combined stdout+stderr is CAPPED at " + "20,000 bytes from the start of output \u2014 do not use sed/grep/cat/awk here to locate code (they can " + "silently truncate before reaching what you need, and repeating the command returns the same truncated " + "head); use search_code or graph_symbol instead, which are built for that and stay within the cap. Set " + "background:true for a long-running command (a dev server, a watch build) \u2014 it returns immediately with " + "{job_id, pid} instead of blocking; poll shell_job_output(job_id) for new output and shell_job_kill" + "(job_id) to stop it.",
81176
+ description: "Run a shell command in the project root (e.g. `git status`, `bun test`). Requires the user's approval " + "before it runs. Input: { command: string, background?: boolean, description?: string, " + "idle_timeout_ms?: integer }. EVERY command returns within a bounded wait \u2014 a short one returns its " + "output directly, and one still running when the wait elapses keeps running as a task: you get " + "{task_id, pid, status, output} instead of its final output, and read the rest with " + "shell_job_output(task_id) and stop it with shell_job_kill(task_id). So a slow command never freezes " + "the session and never needs to be guessed at in advance. background:true only SKIPS the wait (use it " + "for a dev server or a watch build you know you will not read inline); it changes nothing else. " + "description is a short label for the task list. idle_timeout_ms is for a command that is deliberately " + "SILENT for a long time (a sleep, a slow poll) \u2014 a task that produces no output for its idle timeout " + "is killed, so raise it rather than lose the command. Combined stdout+stderr is CAPPED at 20,000 bytes " + "from the start of output \u2014 do not use sed/grep/cat/awk here to locate code (they can silently " + "truncate before reaching what you need, and repeating the command returns the same truncated head); " + "use search_code or graph_symbol instead, which are built for that and stay within the cap.",
81022
81177
  inputSchema: {
81023
81178
  type: "object",
81024
- properties: { command: { type: "string" }, background: { type: "boolean" } },
81179
+ properties: {
81180
+ command: { type: "string" },
81181
+ background: { type: "boolean" },
81182
+ description: { type: "string" },
81183
+ idle_timeout_ms: { type: "integer" }
81184
+ },
81025
81185
  required: ["command"],
81026
81186
  additionalProperties: false
81027
81187
  },
@@ -81032,20 +81192,66 @@ function shellExecTool(root, run = makeCommandRunner(root), jobRegistry) {
81032
81192
  if (command.length === 0) {
81033
81193
  return { output: "shell_exec requires a non-empty 'command'", isError: true };
81034
81194
  }
81035
- if (input.background === true) {
81036
- if (jobRegistry === undefined) {
81195
+ const background = input.background === true;
81196
+ if (jobRegistry === undefined) {
81197
+ if (background) {
81037
81198
  return { output: "shell_exec: background jobs are not available in this session", isError: true };
81038
81199
  }
81039
- const started = await jobRegistry.start(command);
81040
- if (!started.ok) {
81041
- return { output: started.error, isError: true };
81200
+ return run(command);
81201
+ }
81202
+ const startOpts = { phase: background ? "background" : "foreground" };
81203
+ if (typeof input.description === "string" && input.description.length > 0) {
81204
+ startOpts.description = input.description;
81205
+ }
81206
+ if (typeof input.idle_timeout_ms === "number") {
81207
+ startOpts.idleTimeoutMs = clampTaskIdleTimeoutMs(input.idle_timeout_ms);
81208
+ }
81209
+ const started = await jobRegistry.start(command, startOpts);
81210
+ if (!started.ok) {
81211
+ return { output: started.error, isError: true };
81212
+ }
81213
+ const taskId = started.jobId;
81214
+ if (background) {
81215
+ return taskHandleResult({
81216
+ taskId,
81217
+ pid: started.pid,
81218
+ output: started.output,
81219
+ notice: `running as a background task; read new output with shell_job_output("${taskId}") and stop it with shell_job_kill("${taskId}")`
81220
+ });
81221
+ }
81222
+ const yieldMs = options?.yieldMs ?? resolveShellYieldMs();
81223
+ const outcome = await jobRegistry.waitForExit(taskId, yieldMs);
81224
+ let collected = started.output;
81225
+ const pending = jobRegistry.readOutput(taskId);
81226
+ if (pending.ok)
81227
+ collected += pending.output;
81228
+ if (outcome === "timeout") {
81229
+ const promoted = jobRegistry.promote(taskId);
81230
+ if (!promoted.ok) {
81231
+ return { output: promoted.error, isError: true };
81042
81232
  }
81043
- return {
81044
- output: JSON.stringify({ job_id: started.jobId, pid: started.pid, output: started.output }),
81045
- isError: false
81046
- };
81233
+ return taskHandleResult({
81234
+ taskId,
81235
+ pid: started.pid,
81236
+ output: collected,
81237
+ notice: `still running after ${yieldMs}ms, so it is now a background task; read new output with shell_job_output("${taskId}") and stop it with shell_job_kill("${taskId}")`,
81238
+ ...promoted.overCap !== undefined ? { overCap: promoted.overCap } : {}
81239
+ });
81240
+ }
81241
+ const info = jobRegistry.get(taskId);
81242
+ if (outcome === "unknown" || info === undefined) {
81243
+ const so_far = boundOutput2(collected.trim());
81244
+ const lost = `shell_exec: task ${taskId} is no longer tracked, so its exit status is unknown`;
81245
+ return { output: so_far.length > 0 ? `${so_far}
81246
+ ${lost}` : lost, isError: true };
81047
81247
  }
81048
- return run(command);
81248
+ const exitCode = info.exitCode ?? 0;
81249
+ const bounded = boundOutput2((info.outputHead ?? collected).trim());
81250
+ const body = bounded.length > 0 ? bounded : `(no output; exit ${exitCode})`;
81251
+ const idleKilled = info.status === "killed" && info.killReason === "idle";
81252
+ const output = idleKilled ? `${body}
81253
+ shell_exec: no output for ${info.idleTimeoutMs}ms, so the command was killed (raise it for this command with idle_timeout_ms, or change the default with ${ENV_SHELL_IDLE_MS})` : body;
81254
+ return { output, isError: info.status !== "completed" };
81049
81255
  }
81050
81256
  };
81051
81257
  }
@@ -94455,9 +94661,14 @@ function openExternalInspector(otui, chrome, options) {
94455
94661
  var MAX_BACKGROUND_JOB_OUTPUT_CHARS = 20000;
94456
94662
  var STATUS_GLYPH4 = {
94457
94663
  running: "\u25D0",
94458
- exited: "\u25CF",
94664
+ completed: "\u25CF",
94665
+ failed: "\u25B2",
94459
94666
  killed: "\u2717"
94460
94667
  };
94668
+ function appendBounded(output, chunk) {
94669
+ const combined = output + chunk;
94670
+ return combined.length > MAX_BACKGROUND_JOB_OUTPUT_CHARS ? combined.slice(-MAX_BACKGROUND_JOB_OUTPUT_CHARS) : combined;
94671
+ }
94461
94672
  function clip7(s, max) {
94462
94673
  if (max <= 0) {
94463
94674
  return "";
@@ -94486,6 +94697,9 @@ function formatJobMeta(entry, now = Date.now()) {
94486
94697
  ["Ended", entry.endedAt ?? "\u2014"],
94487
94698
  ["Exit code", entry.exitCode !== undefined ? String(entry.exitCode) : "\u2014"]
94488
94699
  ];
94700
+ if (entry.killReason !== undefined) {
94701
+ rows.push(["Kill reason", entry.killReason]);
94702
+ }
94489
94703
  const width = rows.reduce((max, [label]) => Math.max(max, label.length), 0);
94490
94704
  return rows.map(([label, value]) => `${label.padEnd(width)} ${value}`).join(`
94491
94705
  `);
@@ -94496,36 +94710,64 @@ function formatJobOutput(entry) {
94496
94710
 
94497
94711
  class BackgroundJobStore {
94498
94712
  jobs = new Map;
94713
+ pending = new Map;
94499
94714
  listeners = new Set;
94500
94715
  apply(event) {
94501
- if (event.type === "start") {
94502
- this.jobs.set(event.jobId, {
94503
- jobId: event.jobId,
94504
- command: event.command,
94505
- pid: event.pid,
94506
- status: "running",
94507
- startedAt: event.startedAt,
94508
- output: ""
94509
- });
94510
- this.emit({ id: event.jobId, kind: "start" });
94511
- return;
94512
- }
94513
- const current = this.jobs.get(event.jobId);
94514
- if (current === undefined) {
94515
- return;
94516
- }
94517
- if (event.type === "output") {
94518
- const combined = current.output + event.chunk;
94519
- current.output = combined.length > MAX_BACKGROUND_JOB_OUTPUT_CHARS ? combined.slice(-MAX_BACKGROUND_JOB_OUTPUT_CHARS) : combined;
94520
- this.emit({ id: event.jobId, kind: "output" });
94521
- return;
94522
- }
94523
- current.status = event.status;
94524
- current.endedAt = event.endedAt;
94525
- if (event.exitCode !== undefined) {
94526
- current.exitCode = event.exitCode;
94716
+ switch (event.type) {
94717
+ case "start":
94718
+ this.pending.set(event.jobId, {
94719
+ jobId: event.jobId,
94720
+ command: event.command,
94721
+ pid: event.pid,
94722
+ status: "running",
94723
+ startedAt: event.startedAt,
94724
+ output: ""
94725
+ });
94726
+ return;
94727
+ case "phase": {
94728
+ const entry = this.pending.get(event.jobId);
94729
+ if (entry === undefined) {
94730
+ return;
94731
+ }
94732
+ this.pending.delete(event.jobId);
94733
+ this.jobs.set(event.jobId, entry);
94734
+ this.emit({ id: event.jobId, kind: "start" });
94735
+ return;
94736
+ }
94737
+ case "output": {
94738
+ const hidden = this.pending.get(event.jobId);
94739
+ if (hidden !== undefined) {
94740
+ hidden.output = appendBounded(hidden.output, event.chunk);
94741
+ return;
94742
+ }
94743
+ const current = this.jobs.get(event.jobId);
94744
+ if (current === undefined) {
94745
+ return;
94746
+ }
94747
+ current.output = appendBounded(current.output, event.chunk);
94748
+ this.emit({ id: event.jobId, kind: "output" });
94749
+ return;
94750
+ }
94751
+ case "exit": {
94752
+ if (this.pending.delete(event.jobId)) {
94753
+ return;
94754
+ }
94755
+ const current = this.jobs.get(event.jobId);
94756
+ if (current === undefined) {
94757
+ return;
94758
+ }
94759
+ current.status = event.status;
94760
+ current.endedAt = event.endedAt;
94761
+ if (event.exitCode !== undefined) {
94762
+ current.exitCode = event.exitCode;
94763
+ }
94764
+ if (event.killReason !== undefined) {
94765
+ current.killReason = event.killReason;
94766
+ }
94767
+ this.emit({ id: event.jobId, kind: "exit" });
94768
+ return;
94769
+ }
94527
94770
  }
94528
- this.emit({ id: event.jobId, kind: "exit" });
94529
94771
  }
94530
94772
  get(jobId) {
94531
94773
  return this.jobs.get(jobId);
@@ -94540,6 +94782,7 @@ class BackgroundJobStore {
94540
94782
  };
94541
94783
  }
94542
94784
  removeAll() {
94785
+ this.pending.clear();
94543
94786
  if (this.jobs.size === 0) {
94544
94787
  return;
94545
94788
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrciphersmith/keryx",
3
- "version": "0.2.107",
3
+ "version": "0.2.108",
4
4
  "description": "Version-controlled project context for AI coding agents: code graph, architecture wiki, project memory, relevant tests, quality signals, and task flows.",
5
5
  "private": false,
6
6
  "publishConfig": {