@ganglion/xacpx 0.17.1 → 0.18.0

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.
@@ -5698,6 +5698,18 @@ function createDefaultQueueOwnerTerminator(_acpxCommand) {
5698
5698
  await terminateAcpxQueueOwner(sessionId);
5699
5699
  };
5700
5700
  }
5701
+ async function readQueueOwnerPid(sessionId) {
5702
+ let owner;
5703
+ try {
5704
+ owner = JSON.parse(await readFile(queueLockFilePath(sessionId), "utf8"));
5705
+ } catch {
5706
+ return;
5707
+ }
5708
+ if (typeof owner.pid === "number" && Number.isInteger(owner.pid) && owner.pid > 0) {
5709
+ return owner.pid;
5710
+ }
5711
+ return;
5712
+ }
5701
5713
  async function terminateAcpxQueueOwner(sessionId) {
5702
5714
  const lockPath = queueLockFilePath(sessionId);
5703
5715
  let owner;
@@ -5740,6 +5752,88 @@ var init_acpx_queue_owner_launcher = __esm(() => {
5740
5752
  init_i18n();
5741
5753
  });
5742
5754
 
5755
+ // src/runtime/core-home.ts
5756
+ import { existsSync as existsSync2 } from "node:fs";
5757
+ import { join as join4 } from "node:path";
5758
+ function coreHomeDir(home) {
5759
+ const primary = join4(home, CORE_HOME_DIR_NAME);
5760
+ if (existsSync2(primary))
5761
+ return primary;
5762
+ const legacy = join4(home, CORE_HOME_LEGACY_DIR_NAME);
5763
+ if (existsSync2(legacy))
5764
+ return legacy;
5765
+ return primary;
5766
+ }
5767
+ function coreHomeDisplayPath(...segments) {
5768
+ return ["~", CORE_HOME_DIR_NAME, ...segments].join("/");
5769
+ }
5770
+ var CORE_HOME_DIR_NAME = ".xacpx", CORE_HOME_LEGACY_DIR_NAME = ".weacpx";
5771
+ var init_core_home = () => {};
5772
+
5773
+ // src/orchestration/orchestration-ipc.ts
5774
+ import { createHash as createHash3 } from "node:crypto";
5775
+ import { join as join5 } from "node:path";
5776
+ function resolveOrchestrationEndpoint(runtimeDir, platform = process.platform) {
5777
+ if (platform === "win32") {
5778
+ const suffix = createHash3("sha256").update(runtimeDir).digest("hex").slice(0, 12);
5779
+ return {
5780
+ kind: "named-pipe",
5781
+ path: `\\\\.\\pipe\\xacpx-orchestration-${suffix}`
5782
+ };
5783
+ }
5784
+ return {
5785
+ kind: "unix",
5786
+ path: join5(runtimeDir, "orchestration.sock")
5787
+ };
5788
+ }
5789
+ function createOrchestrationEndpoint(path2, platform = process.platform) {
5790
+ return {
5791
+ kind: platform === "win32" || path2.startsWith("\\\\.\\pipe\\") ? "named-pipe" : "unix",
5792
+ path: path2
5793
+ };
5794
+ }
5795
+ function encodeOrchestrationRpcRequest(request) {
5796
+ return `${JSON.stringify(request)}
5797
+ `;
5798
+ }
5799
+ function encodeOrchestrationRpcResponse(response) {
5800
+ return `${JSON.stringify(response)}
5801
+ `;
5802
+ }
5803
+ var init_orchestration_ipc = () => {};
5804
+
5805
+ // src/daemon/daemon-files.ts
5806
+ import { dirname as dirname3, join as join6 } from "node:path";
5807
+ function resolveDaemonPaths(options) {
5808
+ const runtimeDir = options.runtimeDir ?? (options.configPath ? resolveRuntimeDirFromConfigPath(options.configPath) : join6(coreHomeDir(options.home), "runtime"));
5809
+ return {
5810
+ runtimeDir,
5811
+ pidFile: join6(runtimeDir, "daemon.pid"),
5812
+ statusFile: join6(runtimeDir, "status.json"),
5813
+ stdoutLog: join6(runtimeDir, "stdout.log"),
5814
+ stderrLog: join6(runtimeDir, "stderr.log"),
5815
+ appLog: join6(runtimeDir, "app.log")
5816
+ };
5817
+ }
5818
+ function resolveRuntimeDirFromConfigPath(configPath) {
5819
+ return join6(dirname3(configPath), "runtime");
5820
+ }
5821
+ function resolveDaemonOrchestrationSocketPath(runtimeDir, platform = process.platform) {
5822
+ return resolveOrchestrationEndpoint(runtimeDir, platform).path;
5823
+ }
5824
+ function isProcessAlive(pid) {
5825
+ try {
5826
+ process.kill(pid, 0);
5827
+ return true;
5828
+ } catch (error) {
5829
+ return error.code === "EPERM";
5830
+ }
5831
+ }
5832
+ var init_daemon_files = __esm(() => {
5833
+ init_core_home();
5834
+ init_orchestration_ipc();
5835
+ });
5836
+
5743
5837
  // src/util/path.ts
5744
5838
  import path2 from "node:path";
5745
5839
  import { homedir as homedir4 } from "node:os";
@@ -5782,10 +5876,10 @@ var init_path = __esm(() => {
5782
5876
  // src/transport/codex-subagent-filter.ts
5783
5877
  import { closeSync, openSync, readdirSync, readSync, statSync } from "node:fs";
5784
5878
  import { homedir as homedir5 } from "node:os";
5785
- import { join as join4 } from "node:path";
5879
+ import { join as join7 } from "node:path";
5786
5880
  function resolveCodexHome(env = process.env) {
5787
5881
  const fromEnv = env.CODEX_HOME?.trim();
5788
- return fromEnv ? fromEnv : join4(homedir5(), ".codex");
5882
+ return fromEnv ? fromEnv : join7(homedir5(), ".codex");
5789
5883
  }
5790
5884
  function isPlainObject(value) {
5791
5885
  return !!value && typeof value === "object" && !Array.isArray(value);
@@ -5850,7 +5944,7 @@ function filterSubagentSessions(result, isSubagent) {
5850
5944
  };
5851
5945
  }
5852
5946
  function nodeRolloutReader(home) {
5853
- const root = join4(home, "sessions");
5947
+ const root = join7(home, "sessions");
5854
5948
  return {
5855
5949
  listRolloutPaths() {
5856
5950
  const out = [];
@@ -5862,7 +5956,7 @@ function nodeRolloutReader(home) {
5862
5956
  return;
5863
5957
  }
5864
5958
  for (const entry of entries) {
5865
- const full = join4(dir, entry.name);
5959
+ const full = join7(dir, entry.name);
5866
5960
  if (entry.isDirectory())
5867
5961
  walk(full);
5868
5962
  else if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl"))
@@ -5978,11 +6072,11 @@ var init_agent_session_list = __esm(() => {
5978
6072
  // src/transport/acpx-session-files.ts
5979
6073
  import { readdir, unlink as unlink2 } from "node:fs/promises";
5980
6074
  import { homedir as homedir6 } from "node:os";
5981
- import { join as join5 } from "node:path";
6075
+ import { join as join8 } from "node:path";
5982
6076
  async function deleteAcpxSessionFiles(options) {
5983
- const dir = options.sessionsDir ?? join5(homedir6(), ".acpx", "sessions");
6077
+ const dir = options.sessionsDir ?? join8(homedir6(), ".acpx", "sessions");
5984
6078
  const safeId = encodeURIComponent(options.acpxRecordId);
5985
- await unlink2(join5(dir, `${safeId}.json`)).catch(() => {
6079
+ await unlink2(join8(dir, `${safeId}.json`)).catch(() => {
5986
6080
  return;
5987
6081
  });
5988
6082
  let entries;
@@ -5993,7 +6087,7 @@ async function deleteAcpxSessionFiles(options) {
5993
6087
  }
5994
6088
  const streamFiles = entries.filter((name) => name.startsWith(`${safeId}.stream.`));
5995
6089
  for (const name of streamFiles) {
5996
- await unlink2(join5(dir, name)).catch(() => {
6090
+ await unlink2(join8(dir, name)).catch(() => {
5997
6091
  return;
5998
6092
  });
5999
6093
  }
@@ -6260,7 +6354,7 @@ init_prompt_media();
6260
6354
  init_streaming_prompt();
6261
6355
  import { copyFile, readdir as readdir2 } from "node:fs/promises";
6262
6356
  import { homedir as homedir7 } from "node:os";
6263
- import { dirname as dirname3, join as join6, win32 } from "node:path";
6357
+ import { dirname as dirname4, join as join9, win32 } from "node:path";
6264
6358
  import { spawn as spawn4 } from "node:child_process";
6265
6359
 
6266
6360
  // src/bridge/parse-missing-optional-dep.ts
@@ -6279,6 +6373,7 @@ function parseMissingOptionalDep(text) {
6279
6373
  // src/bridge/bridge-runtime.ts
6280
6374
  init_discover_parent_package_paths();
6281
6375
  init_acpx_queue_owner_launcher();
6376
+ init_daemon_files();
6282
6377
  init_agent_session_list();
6283
6378
  init_codex_subagent_filter();
6284
6379
  init_acpx_session_files();
@@ -6343,6 +6438,7 @@ class BridgeRuntime {
6343
6438
  });
6344
6439
  }
6345
6440
  async resumeAgentSession(input) {
6441
+ this.invalidateRecordIdCache(input);
6346
6442
  const spawnSpec = resolveSpawnCommand(this.command, this.buildSessionArgs(input, [
6347
6443
  "sessions",
6348
6444
  "new",
@@ -6412,6 +6508,7 @@ class BridgeRuntime {
6412
6508
  throw new Error(message);
6413
6509
  }
6414
6510
  async ensureSession(input, onProgress) {
6511
+ this.invalidateRecordIdCache(input);
6415
6512
  try {
6416
6513
  return await this.attemptEnsureSession(input, onProgress);
6417
6514
  } catch (error) {
@@ -6526,7 +6623,7 @@ class BridgeRuntime {
6526
6623
  const resolved = __require.resolve(`${candidate}/package.json`, {
6527
6624
  paths: [process.cwd(), ...__require.resolve.paths(candidate) ?? []]
6528
6625
  });
6529
- return dirname3(resolved);
6626
+ return dirname4(resolved);
6530
6627
  } catch {
6531
6628
  continue;
6532
6629
  }
@@ -6713,6 +6810,7 @@ class BridgeRuntime {
6713
6810
  };
6714
6811
  }
6715
6812
  async removeSession(input) {
6813
+ this.invalidateRecordIdCache(input);
6716
6814
  const spawnSpec = resolveSpawnCommand(this.command, this.buildSessionArgs(input, [
6717
6815
  "sessions",
6718
6816
  "close",
@@ -6751,6 +6849,27 @@ class BridgeRuntime {
6751
6849
  await terminateAcpxQueueOwner(acpxRecordId);
6752
6850
  return {};
6753
6851
  }
6852
+ recordIdCache = new Map;
6853
+ recordIdCacheKey(input) {
6854
+ return JSON.stringify([input.agent, input.agentCommand ?? null, input.cwd, input.name]);
6855
+ }
6856
+ invalidateRecordIdCache(input) {
6857
+ this.recordIdCache.delete(this.recordIdCacheKey(input));
6858
+ }
6859
+ async isSessionWarm(input) {
6860
+ const cacheKey = this.recordIdCacheKey(input);
6861
+ let acpxRecordId = this.recordIdCache.get(cacheKey);
6862
+ if (!acpxRecordId) {
6863
+ try {
6864
+ ({ acpxRecordId } = await this.readSessionRecord(input));
6865
+ } catch {
6866
+ return { warm: false };
6867
+ }
6868
+ this.recordIdCache.set(cacheKey, acpxRecordId);
6869
+ }
6870
+ const pid = await readQueueOwnerPid(acpxRecordId);
6871
+ return { warm: pid !== undefined && isProcessAlive(pid) };
6872
+ }
6754
6873
  async shutdown() {
6755
6874
  return {};
6756
6875
  }
@@ -6941,7 +7060,7 @@ async function tryRepairAcpxSessionIndex(deps = {}) {
6941
7060
  if (!home) {
6942
7061
  return false;
6943
7062
  }
6944
- const pathJoin = platform === "win32" ? win32.join : join6;
7063
+ const pathJoin = platform === "win32" ? win32.join : join9;
6945
7064
  const sessionsDir = pathJoin(home, ".acpx", "sessions");
6946
7065
  const indexPath = pathJoin(sessionsDir, "index.json");
6947
7066
  const readdirFn = deps.readdirFn ?? readdir2;
@@ -6991,6 +7110,7 @@ var BRIDGE_METHODS = new Set([
6991
7110
  "removeSession",
6992
7111
  "deleteSession",
6993
7112
  "freeWarmProcess",
7113
+ "isSessionWarm",
6994
7114
  "getAgentSessionId"
6995
7115
  ]);
6996
7116
  var SESSION_SCOPED_METHODS = new Set([
@@ -7008,6 +7128,7 @@ var SESSION_SCOPED_METHODS = new Set([
7008
7128
  "removeSession",
7009
7129
  "deleteSession",
7010
7130
  "freeWarmProcess",
7131
+ "isSessionWarm",
7011
7132
  "getAgentSessionId"
7012
7133
  ]);
7013
7134
 
@@ -7068,7 +7189,7 @@ class BridgeServer {
7068
7189
  if (!sessionKey) {
7069
7190
  return await this.dispatch(requestId, method, params, writeLine);
7070
7191
  }
7071
- const lane = method === "cancel" ? "control" : "normal";
7192
+ const lane = method === "cancel" || method === "isSessionWarm" ? "control" : "normal";
7072
7193
  return await this.scheduler.run(sessionKey, lane, () => this.dispatch(requestId, method, params, writeLine));
7073
7194
  }
7074
7195
  async dispatch(requestId, method, params, writeLine) {
@@ -7279,6 +7400,14 @@ class BridgeServer {
7279
7400
  cwd: requireString(params, "cwd"),
7280
7401
  name: requireString(params, "name")
7281
7402
  });
7403
+ case "isSessionWarm":
7404
+ return await this.runtime.isSessionWarm({
7405
+ agent: requireString(params, "agent"),
7406
+ ...agentExecutionSettings(params),
7407
+ agentCommand: asOptionalString(params.agentCommand),
7408
+ cwd: requireString(params, "cwd"),
7409
+ name: requireString(params, "name")
7410
+ });
7282
7411
  case "getAgentSessionId":
7283
7412
  return await this.runtime.getAgentSessionId({
7284
7413
  agent: requireString(params, "agent"),
package/dist/cli.js CHANGED
@@ -32133,6 +32133,18 @@ function createDefaultQueueOwnerTerminator(_acpxCommand) {
32133
32133
  await terminateAcpxQueueOwner(sessionId);
32134
32134
  };
32135
32135
  }
32136
+ async function readQueueOwnerPid(sessionId) {
32137
+ let owner;
32138
+ try {
32139
+ owner = JSON.parse(await readFile13(queueLockFilePath(sessionId), "utf8"));
32140
+ } catch {
32141
+ return;
32142
+ }
32143
+ if (typeof owner.pid === "number" && Number.isInteger(owner.pid) && owner.pid > 0) {
32144
+ return owner.pid;
32145
+ }
32146
+ return;
32147
+ }
32136
32148
  async function terminateAcpxQueueOwner(sessionId) {
32137
32149
  const lockPath = queueLockFilePath(sessionId);
32138
32150
  let owner;
@@ -32989,6 +33001,10 @@ ${result.text}` : "" };
32989
33001
  async freeWarmProcess(session3) {
32990
33002
  await this.client.request("freeWarmProcess", this.toParams(session3));
32991
33003
  }
33004
+ async isSessionWarm(session3) {
33005
+ const result = await this.client.request("isSessionWarm", this.toParams(session3));
33006
+ return result.warm === true;
33007
+ }
32992
33008
  async getAgentSessionId(session3) {
32993
33009
  const result = await this.client.request("getAgentSessionId", this.toParams(session3));
32994
33010
  return result.agentSessionId;
@@ -34105,6 +34121,7 @@ class AcpxCliTransport {
34105
34121
  this.resolveSpawnEnvironment = options.resolveSpawnEnvironment ?? resolveClaudeSpawnEnvironment;
34106
34122
  }
34107
34123
  async ensureSession(session3, _onProgress) {
34124
+ this.invalidateRecordIdCache(session3);
34108
34125
  try {
34109
34126
  await this.runEnsureSession(session3);
34110
34127
  } catch (error2) {
@@ -34317,6 +34334,7 @@ ${baseText}` : "" };
34317
34334
  };
34318
34335
  }
34319
34336
  async resumeAgentSession(session3, agentSessionId) {
34337
+ this.invalidateRecordIdCache(session3);
34320
34338
  const args = this.buildArgs(session3, [
34321
34339
  "sessions",
34322
34340
  "new",
@@ -34337,6 +34355,7 @@ ${baseText}` : "" };
34337
34355
  this.permissionPolicy = policy.permissionPolicy;
34338
34356
  }
34339
34357
  async removeSession(session3) {
34358
+ this.invalidateRecordIdCache(session3);
34340
34359
  const result = await this.runCommandWithTimeout(this.runCommand, this.buildArgs(session3, [
34341
34360
  "sessions",
34342
34361
  "close",
@@ -34373,6 +34392,30 @@ ${baseText}` : "" };
34373
34392
  }
34374
34393
  await terminateAcpxQueueOwner(acpxRecordId);
34375
34394
  }
34395
+ recordIdCache = new Map;
34396
+ recordIdCacheKey(session3) {
34397
+ return JSON.stringify([session3.agent, session3.agentCommand ?? null, session3.cwd, session3.transportSession]);
34398
+ }
34399
+ invalidateRecordIdCache(session3) {
34400
+ this.recordIdCache.delete(this.recordIdCacheKey(session3));
34401
+ }
34402
+ async isSessionWarm(session3) {
34403
+ const cacheKey = this.recordIdCacheKey(session3);
34404
+ let acpxRecordId = this.recordIdCache.get(cacheKey);
34405
+ if (!acpxRecordId) {
34406
+ try {
34407
+ ({ acpxRecordId } = await this.readSessionRecord(session3));
34408
+ } catch {
34409
+ return false;
34410
+ }
34411
+ this.recordIdCache.set(cacheKey, acpxRecordId);
34412
+ }
34413
+ const pid = await readQueueOwnerPid(acpxRecordId);
34414
+ if (pid === undefined) {
34415
+ return false;
34416
+ }
34417
+ return isProcessAlive(pid);
34418
+ }
34376
34419
  async hasSession(session3) {
34377
34420
  const result = await this.runCommandWithTimeout(this.runCommand, this.buildArgs(session3, [
34378
34421
  "sessions",
@@ -34705,6 +34748,7 @@ var init_acpx_cli_transport = __esm(() => {
34705
34748
  init_node_pty_helper();
34706
34749
  init_terminate_process_tree();
34707
34750
  init_acpx_queue_owner_launcher();
34751
+ init_daemon_files();
34708
34752
  init_agent_session_list();
34709
34753
  init_codex_subagent_filter();
34710
34754
  init_acpx_session_files();
@@ -36444,7 +36488,7 @@ class WorkspaceFs {
36444
36488
  }
36445
36489
  const files = [];
36446
36490
  try {
36447
- const { stdout: stdout2 } = await execFileAsync("git", ["-C", root, "-c", "core.quotePath=false", "status", "--porcelain", "-z"], { maxBuffer: GIT_MAX_BUFFER, timeout: GIT_TIMEOUT_MS, killSignal: "SIGKILL" });
36491
+ const { stdout: stdout2 } = await execFileAsync("git", ["-C", root, "-c", "core.quotePath=false", "status", "--porcelain", "-z", "--untracked-files=all"], { maxBuffer: GIT_MAX_BUFFER, timeout: GIT_TIMEOUT_MS, killSignal: "SIGKILL" });
36448
36492
  const fields = stdout2.split("\x00");
36449
36493
  for (let i = 0;i < fields.length; i++) {
36450
36494
  const field = fields[i];
@@ -36672,9 +36716,10 @@ class WorkspaceGit {
36672
36716
  return (await this.runRaw(root, args)).trim();
36673
36717
  }
36674
36718
  async runRaw(root, args) {
36719
+ const fullArgs = ["-C", root, "-c", "gc.auto=0", ...args];
36675
36720
  if (this.runGitOverride)
36676
- return await this.runGitOverride(root, args);
36677
- const result = await execFileAsync2("git", ["-C", root, ...args], {
36721
+ return await this.runGitOverride(root, fullArgs);
36722
+ const result = await execFileAsync2("git", fullArgs, {
36678
36723
  timeout: GIT_TIMEOUT_MS2,
36679
36724
  killSignal: "SIGKILL",
36680
36725
  maxBuffer: GIT_MAX_BUFFER2
@@ -36950,19 +36995,21 @@ class WorkspaceGit {
36950
36995
  ...worktreePath ? { worktreePath } : {}
36951
36996
  };
36952
36997
  });
36953
- const statusRaw = await this.runRaw(root, ["-c", "core.quotePath=false", "status", "--porcelain", "-z"]);
36954
36998
  const files = [];
36955
- const fields = statusRaw.split("\x00");
36956
- for (let index = 0;index < fields.length; index += 1) {
36957
- const field = fields[index];
36958
- if (!field)
36959
- continue;
36960
- const status = field.slice(0, 2);
36961
- const path14 = field.slice(3);
36962
- if (status[0] === "R" || status[0] === "C")
36963
- index += 1;
36964
- files.push({ path: path14, status });
36965
- }
36999
+ try {
37000
+ const statusRaw = await this.runRaw(root, ["-c", "core.quotePath=false", "status", "--porcelain", "-z", "--untracked-files=all"]);
37001
+ const fields = statusRaw.split("\x00");
37002
+ for (let index = 0;index < fields.length; index += 1) {
37003
+ const field = fields[index];
37004
+ if (!field)
37005
+ continue;
37006
+ const status = field.slice(0, 2);
37007
+ const path14 = field.slice(3);
37008
+ if (status[0] === "R" || status[0] === "C")
37009
+ index += 1;
37010
+ files.push({ path: path14, status });
37011
+ }
37012
+ } catch {}
36966
37013
  files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
36967
37014
  return {
36968
37015
  workspace: workspace3,
@@ -37069,6 +37116,11 @@ class SessionTurnRunner {
37069
37116
  if (wasArchived) {
37070
37117
  this.deps.events.emit({ type: "sessions-changed" });
37071
37118
  }
37119
+ if (internalAlias && this.deps.sessionWarmth) {
37120
+ const bound = await this.deps.sessions.getSession(internalAlias).catch(() => null);
37121
+ if (bound)
37122
+ this.deps.sessionWarmth.markWarm(bound);
37123
+ }
37072
37124
  this.deps.events.emit({
37073
37125
  type: "turn-started",
37074
37126
  chatKey: req.chatKey,
@@ -37685,6 +37737,7 @@ class ControlService {
37685
37737
  if (!session3)
37686
37738
  throw new Error("session not found");
37687
37739
  await this.deps.sessions.setDisplayName(session3.alias, displayName);
37740
+ this.deps.events.emit({ type: "sessions-changed" });
37688
37741
  }
37689
37742
  async resolveControlSession(chatKey, alias) {
37690
37743
  const internalAlias = await this.deps.sessions.resolveAliasForChat(chatKey, alias);
@@ -37695,17 +37748,22 @@ class ControlService {
37695
37748
  }
37696
37749
  listSessions(chatKey) {
37697
37750
  const channelId = getChannelIdFromChatKey(chatKey);
37698
- return this.deps.sessions.listAllResolvedSessions().filter((session3) => isSessionAliasVisibleInChannel(session3.alias, channelId)).map((session3) => ({
37699
- alias: toDisplaySessionAlias(session3.alias),
37700
- agent: session3.agent,
37701
- workspace: session3.workspace,
37702
- transportSession: session3.transportSession,
37703
- running: this.deps.activeTurns.isActiveAnywhere(session3.alias),
37704
- archived: session3.archived === true,
37705
- ...session3.source === "agent-side" ? { native: true } : {},
37706
- ...session3.agentCommand ? { agentCommand: session3.agentCommand } : {},
37707
- ...session3.displayName ? { displayName: session3.displayName } : {}
37708
- }));
37751
+ return this.deps.sessions.listAllResolvedSessions().filter((session3) => isSessionAliasVisibleInChannel(session3.alias, channelId)).map((session3) => {
37752
+ const running = this.deps.activeTurns.isActiveAnywhere(session3.alias);
37753
+ const warm = running ? true : this.deps.sessionWarmth?.isWarm(session3);
37754
+ return {
37755
+ alias: toDisplaySessionAlias(session3.alias),
37756
+ agent: session3.agent,
37757
+ workspace: session3.workspace,
37758
+ transportSession: session3.transportSession,
37759
+ running,
37760
+ archived: session3.archived === true,
37761
+ ...warm !== undefined ? { warm } : {},
37762
+ ...session3.source === "agent-side" ? { native: true } : {},
37763
+ ...session3.agentCommand ? { agentCommand: session3.agentCommand } : {},
37764
+ ...session3.displayName ? { displayName: session3.displayName } : {}
37765
+ };
37766
+ });
37709
37767
  }
37710
37768
  async listNativeSessions(_chatKey, agent3, workspace3) {
37711
37769
  const sessions = await this.deps.listNativeSessions(agent3, workspace3);
@@ -37747,6 +37805,11 @@ class ControlService {
37747
37805
  async archiveSession(chatKey, alias) {
37748
37806
  const internalAlias = await this.deps.sessions.resolveAliasForChat(chatKey, alias);
37749
37807
  await this.deps.archiveSessionWithTransport(internalAlias);
37808
+ const session3 = await this.deps.sessions.getSession(internalAlias).catch(() => {
37809
+ return;
37810
+ });
37811
+ if (session3)
37812
+ this.deps.sessionWarmth?.markCold(session3);
37750
37813
  this.deps.events.emit({ type: "sessions-changed" });
37751
37814
  }
37752
37815
  async unarchiveSession(chatKey, alias) {
@@ -37894,6 +37957,98 @@ var init_control_service = __esm(() => {
37894
37957
  MODEL_SET_SETTLE_BUDGET_MS = 2 * (DEFAULT_MANAGEMENT_COMMAND_TIMEOUT_MS + BRIDGE_REQUEST_TIMEOUT_GRACE_MS);
37895
37958
  });
37896
37959
 
37960
+ // src/control/session-warmth-tracker.ts
37961
+ class SessionWarmthTracker {
37962
+ listSessions;
37963
+ checkWarm;
37964
+ events;
37965
+ logger;
37966
+ intervalMs;
37967
+ setIntervalFn;
37968
+ clearIntervalFn;
37969
+ warmth = new Map;
37970
+ intervalHandle = null;
37971
+ ticking = false;
37972
+ constructor(deps) {
37973
+ this.listSessions = deps.listSessions;
37974
+ this.checkWarm = deps.isWarm;
37975
+ this.events = deps.events;
37976
+ this.logger = deps.logger;
37977
+ this.intervalMs = deps.intervalMs ?? 60000;
37978
+ this.setIntervalFn = deps.setIntervalFn ?? ((fn, delay) => setInterval(fn, delay));
37979
+ this.clearIntervalFn = deps.clearIntervalFn ?? ((timer) => clearInterval(timer));
37980
+ }
37981
+ start() {
37982
+ if (this.intervalHandle !== null)
37983
+ return;
37984
+ this.intervalHandle = this.setIntervalFn(() => {
37985
+ this.tick();
37986
+ }, this.intervalMs);
37987
+ this.tick();
37988
+ }
37989
+ stop() {
37990
+ if (this.intervalHandle !== null) {
37991
+ this.clearIntervalFn(this.intervalHandle);
37992
+ this.intervalHandle = null;
37993
+ }
37994
+ }
37995
+ isWarm(session3) {
37996
+ return this.warmth.get(warmthKey(session3));
37997
+ }
37998
+ markWarm(session3) {
37999
+ this.warmth.set(warmthKey(session3), true);
38000
+ }
38001
+ markCold(session3) {
38002
+ this.warmth.set(warmthKey(session3), false);
38003
+ }
38004
+ async tick() {
38005
+ if (this.ticking)
38006
+ return;
38007
+ this.ticking = true;
38008
+ try {
38009
+ const sessions = this.listSessions();
38010
+ const seen = new Set;
38011
+ let flipped = false;
38012
+ for (const session3 of sessions) {
38013
+ const key = warmthKey(session3);
38014
+ if (seen.has(key))
38015
+ continue;
38016
+ seen.add(key);
38017
+ let warm;
38018
+ try {
38019
+ warm = await this.checkWarm(session3);
38020
+ } catch (error2) {
38021
+ await this.logger?.error("warmth.check_failed", "session warmth check threw; keeping previous value", {
38022
+ transportSession: session3.transportSession,
38023
+ message: error2 instanceof Error ? error2.message : String(error2)
38024
+ });
38025
+ continue;
38026
+ }
38027
+ if (this.warmth.get(key) !== warm) {
38028
+ this.warmth.set(key, warm);
38029
+ flipped = true;
38030
+ }
38031
+ }
38032
+ for (const key of [...this.warmth.keys()]) {
38033
+ if (!seen.has(key))
38034
+ this.warmth.delete(key);
38035
+ }
38036
+ if (flipped) {
38037
+ this.events.emit({ type: "sessions-changed" });
38038
+ }
38039
+ } catch (error2) {
38040
+ await this.logger?.error("warmth.tick_failed", "session warmth tick threw", {
38041
+ message: error2 instanceof Error ? error2.message : String(error2)
38042
+ });
38043
+ } finally {
38044
+ this.ticking = false;
38045
+ }
38046
+ }
38047
+ }
38048
+ function warmthKey(session3) {
38049
+ return JSON.stringify([session3.agent, session3.agentCommand ?? null, session3.cwd, session3.transportSession]);
38050
+ }
38051
+
37897
38052
  // src/control/terminal-service.ts
37898
38053
  import { randomUUID as randomUUID4 } from "node:crypto";
37899
38054
  import { createRequire as createRequire6 } from "node:module";
@@ -38798,6 +38953,13 @@ async function buildApp(paths, deps = {}) {
38798
38953
  const loadAgentRegistry = createAcpxAgentRegistryLoader({ logger });
38799
38954
  uploadStore.cleanup();
38800
38955
  const uploadCleanupInterval = setInterval(() => void uploadStore.cleanup().catch(() => {}), 60 * 60 * 1000);
38956
+ const sessionWarmth = transport.isSessionWarm ? new SessionWarmthTracker({
38957
+ listSessions: () => sessions.listAllResolvedSessions(),
38958
+ isWarm: (session3) => transport.isSessionWarm(session3),
38959
+ events: controlEvents,
38960
+ logger
38961
+ }) : undefined;
38962
+ sessionWarmth?.start();
38801
38963
  const control = new ControlService({
38802
38964
  logger,
38803
38965
  agent: agent3,
@@ -38810,6 +38972,7 @@ async function buildApp(paths, deps = {}) {
38810
38972
  archiveSessionWithTransport: (internalAlias) => router3.archiveSessionWithTransport(internalAlias),
38811
38973
  unarchiveSession: (internalAlias) => router3.unarchiveSession(internalAlias),
38812
38974
  activeTurns,
38975
+ ...sessionWarmth ? { sessionWarmth } : {},
38813
38976
  scheduled: scheduledService,
38814
38977
  orchestration: orchestration3,
38815
38978
  events: controlEvents,
@@ -38945,6 +39108,7 @@ async function buildApp(paths, deps = {}) {
38945
39108
  reapStaleQueueOwners: () => reapWarmQueueOwners("startup"),
38946
39109
  dispose: async () => {
38947
39110
  scheduledScheduler.stop();
39111
+ sessionWarmth?.stop();
38948
39112
  configWatcher.close();
38949
39113
  clearInterval(uploadCleanupInterval);
38950
39114
  terminalService.disposeAll();
@@ -25,6 +25,10 @@ export interface ControlSessionInfo {
25
25
  transportSession: string;
26
26
  running: boolean;
27
27
  archived: boolean;
28
+ /** Whether the session's agent process is currently alive (next prompt responds
29
+ * without a cold start). Omitted when unknown — e.g. the transport can't observe
30
+ * liveness or the warmth tracker hasn't sampled this session yet. */
31
+ warm?: boolean;
28
32
  /** True when this logical session was attached to an existing agent-side (native) rollout
29
33
  * rather than freshly created. Mirrors LogicalSession.source === "agent-side"; omitted for
30
34
  * fresh xacpx sessions so the wire stays minimal. */
@@ -72,6 +76,8 @@ export interface ControlServiceDeps {
72
76
  updatedAt?: string;
73
77
  }) => Promise<ResolvedSession>;
74
78
  activeTurns: Pick<ActiveTurnRegistry, "isActiveAnywhere">;
79
+ /** Warmth tracker view for the cold-session indicator; absent ⇒ `warm` omitted from listings. */
80
+ sessionWarmth?: Pick<import("./session-warmth-tracker").SessionWarmthTracker, "isWarm" | "markWarm" | "markCold">;
75
81
  scheduled: Pick<ScheduledTaskService, "listPending" | "listRecentForChat" | "createTask" | "cancelPending">;
76
82
  orchestration: Pick<OrchestrationService, "listTasks" | "getTask" | "requestTaskCancellation">;
77
83
  events: ControlEventBus;
@@ -26,7 +26,7 @@ export interface TurnResult {
26
26
  }
27
27
  export declare class SessionTurnRunner {
28
28
  private readonly deps;
29
- constructor(deps: Pick<ControlServiceDeps, "agent" | "sessions" | "events" | "uploadStore">);
29
+ constructor(deps: Pick<ControlServiceDeps, "agent" | "sessions" | "events" | "uploadStore" | "sessionWarmth">);
30
30
  run(req: TurnRequest, signal: AbortSignal, onActivity?: () => void): Promise<TurnResult>;
31
31
  /** Resolve a chat-scoped display alias to its ResolvedSession and read whether it is
32
32
  * in stream reply mode. Best-effort: any resolution failure falls back to batched
@@ -0,0 +1,42 @@
1
+ import type { AppLogger } from "../logging/app-logger";
2
+ import type { ResolvedSession } from "../transport/types";
3
+ import type { ControlEventBus } from "./control-event-bus";
4
+ export interface SessionWarmthTrackerDeps {
5
+ listSessions: () => ResolvedSession[];
6
+ isWarm: (session: ResolvedSession) => Promise<boolean>;
7
+ events: ControlEventBus;
8
+ logger?: AppLogger;
9
+ intervalMs?: number;
10
+ setIntervalFn?: (fn: () => void | Promise<void>, delay: number) => unknown;
11
+ clearIntervalFn?: (timer: unknown) => void;
12
+ }
13
+ /**
14
+ * Polls queue-owner process liveness for every logical session and emits a
15
+ * payload-free `sessions-changed` control event whenever any session's warmth
16
+ * flips (e.g. a silent TTL expiry), so connected dashboards re-fetch the list
17
+ * and refresh their cold indicators. `isWarm` gives the last observed value
18
+ * synchronously for `control.sessions.list`.
19
+ */
20
+ export declare class SessionWarmthTracker {
21
+ private readonly listSessions;
22
+ private readonly checkWarm;
23
+ private readonly events;
24
+ private readonly logger?;
25
+ private readonly intervalMs;
26
+ private readonly setIntervalFn;
27
+ private readonly clearIntervalFn;
28
+ private readonly warmth;
29
+ private intervalHandle;
30
+ private ticking;
31
+ constructor(deps: SessionWarmthTrackerDeps);
32
+ start(): void;
33
+ stop(): void;
34
+ /** Last observed warmth for this session's transport; undefined until first checked. */
35
+ isWarm(session: ResolvedSession): boolean | undefined;
36
+ /** Immediate corrections from call sites that just changed warmth themselves
37
+ * (archive kills the owner; a starting turn warms it). No event — the caller's
38
+ * own flow already emits one. */
39
+ markWarm(session: ResolvedSession): void;
40
+ markCold(session: ResolvedSession): void;
41
+ tick(): Promise<void>;
42
+ }
@@ -232,6 +232,13 @@ export interface SessionTransport {
232
232
  * Optional: transports that can't reap omit it.
233
233
  */
234
234
  freeWarmProcess?(session: ResolvedSession): Promise<void>;
235
+ /**
236
+ * Whether this session's warm queue-owner process is currently alive (=
237
+ * the next prompt responds immediately instead of cold-starting). False
238
+ * when the owner exited via TTL, archive, or any other reason. Optional:
239
+ * transports that can't observe process liveness omit it.
240
+ */
241
+ isSessionWarm?(session: ResolvedSession): Promise<boolean>;
235
242
  /**
236
243
  * Read the underlying agent-native session id for an existing transport
237
244
  * session. Used by `/clear` to keep a native session native: the fresh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ganglion/xacpx",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "description": "随时随地通过聊天频道(微信 / 飞书 / 元宝等)远程控制 `acpx` 上的 Claude Code、Codex 等 Agents。",
5
5
  "keywords": [
6
6
  "acpx",