claude-threads 0.30.0 → 0.31.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.31.0] - 2026-01-03
11
+
12
+ ### Added
13
+ - **Session history retention** - Sessions are now soft-deleted instead of permanently removed when they complete. Session history is kept for display in the sticky message (up to 5 recent sessions). Old history is permanently cleaned up after 3 days.
14
+ - **Git branch in session header** - Display the current git branch in the session header table when working in a git repository, providing visibility into which branch the session is operating on.
15
+
16
+ ### Fixed
17
+ - **Accurate context usage via status line** - Uses Claude Code's status line feature to get accurate context window usage percentage instead of cumulative billing tokens. Adds a status line writer script that receives accurate per-request token data.
18
+
10
19
  ## [0.30.0] - 2026-01-03
11
20
 
12
21
  ### Added
package/dist/index.js CHANGED
@@ -13722,10 +13722,12 @@ class SessionStore {
13722
13722
  return sessions;
13723
13723
  }
13724
13724
  for (const session of Object.values(data.sessions)) {
13725
+ if (session.cleanedAt)
13726
+ continue;
13725
13727
  const sessionId = `${session.platformId}:${session.threadId}`;
13726
13728
  sessions.set(sessionId, session);
13727
13729
  }
13728
- log3.debug(`Loaded ${sessions.size} session(s)`);
13730
+ log3.debug(`Loaded ${sessions.size} active session(s)`);
13729
13731
  } catch (err) {
13730
13732
  log3.error(`Failed to load sessions: ${err}`);
13731
13733
  }
@@ -13747,23 +13749,67 @@ class SessionStore {
13747
13749
  log3.debug(`Removed session ${shortId}...`);
13748
13750
  }
13749
13751
  }
13752
+ softDelete(sessionId) {
13753
+ const data = this.loadRaw();
13754
+ if (data.sessions[sessionId]) {
13755
+ data.sessions[sessionId].cleanedAt = new Date().toISOString();
13756
+ this.writeAtomic(data);
13757
+ const shortId = sessionId.substring(0, 20);
13758
+ log3.debug(`Soft-deleted session ${shortId}...`);
13759
+ }
13760
+ }
13750
13761
  cleanStale(maxAgeMs) {
13751
13762
  const data = this.loadRaw();
13752
13763
  const now = Date.now();
13753
13764
  const staleIds = [];
13754
13765
  for (const [sessionId, session] of Object.entries(data.sessions)) {
13766
+ if (session.cleanedAt)
13767
+ continue;
13755
13768
  const lastActivity = new Date(session.lastActivityAt).getTime();
13756
13769
  if (now - lastActivity > maxAgeMs) {
13757
13770
  staleIds.push(sessionId);
13758
- delete data.sessions[sessionId];
13771
+ session.cleanedAt = new Date().toISOString();
13759
13772
  }
13760
13773
  }
13761
13774
  if (staleIds.length > 0) {
13762
13775
  this.writeAtomic(data);
13763
- log3.debug(`Cleaned ${staleIds.length} stale session(s)`);
13776
+ log3.debug(`Soft-deleted ${staleIds.length} stale session(s)`);
13764
13777
  }
13765
13778
  return staleIds;
13766
13779
  }
13780
+ cleanHistory(historyRetentionMs = 3 * 24 * 60 * 60 * 1000) {
13781
+ const data = this.loadRaw();
13782
+ const now = Date.now();
13783
+ let removedCount = 0;
13784
+ for (const [sessionId, session] of Object.entries(data.sessions)) {
13785
+ if (!session.cleanedAt)
13786
+ continue;
13787
+ const cleanedTime = new Date(session.cleanedAt).getTime();
13788
+ if (now - cleanedTime > historyRetentionMs) {
13789
+ delete data.sessions[sessionId];
13790
+ removedCount++;
13791
+ }
13792
+ }
13793
+ if (removedCount > 0) {
13794
+ this.writeAtomic(data);
13795
+ log3.debug(`Permanently removed ${removedCount} old session(s) from history`);
13796
+ }
13797
+ return removedCount;
13798
+ }
13799
+ getHistory(platformId) {
13800
+ const data = this.loadRaw();
13801
+ const historySessions = [];
13802
+ for (const session of Object.values(data.sessions)) {
13803
+ if (session.platformId === platformId && session.cleanedAt) {
13804
+ historySessions.push(session);
13805
+ }
13806
+ }
13807
+ return historySessions.sort((a, b) => {
13808
+ const aTime = new Date(a.cleanedAt).getTime();
13809
+ const bTime = new Date(b.cleanedAt).getTime();
13810
+ return bTime - aTime;
13811
+ });
13812
+ }
13767
13813
  clear() {
13768
13814
  const data = this.loadRaw();
13769
13815
  this.writeAtomic({ version: STORE_VERSION, sessions: {}, stickyPostIds: data.stickyPostIds });
@@ -15503,12 +15549,35 @@ function updateUsageStats(session, event, ctx) {
15503
15549
  const STATUS_BAR_UPDATE_INTERVAL = 30000;
15504
15550
  session.statusBarTimer = setInterval(() => {
15505
15551
  if (session.claude.isRunning()) {
15552
+ updateUsageFromStatusLine(session);
15506
15553
  ctx.ops.updateSessionHeader(session).catch(() => {});
15507
15554
  }
15508
15555
  }, STATUS_BAR_UPDATE_INTERVAL);
15509
15556
  }
15510
15557
  ctx.ops.updateSessionHeader(session).catch(() => {});
15511
15558
  }
15559
+ function updateUsageFromStatusLine(session) {
15560
+ const statusData = session.claude.getStatusData();
15561
+ if (!statusData || !statusData.current_usage)
15562
+ return;
15563
+ if (!session.usageStats)
15564
+ return;
15565
+ const contextTokens = statusData.current_usage.input_tokens + statusData.current_usage.cache_creation_input_tokens + statusData.current_usage.cache_read_input_tokens;
15566
+ if (statusData.timestamp > session.usageStats.lastUpdated.getTime()) {
15567
+ session.usageStats.contextTokens = contextTokens;
15568
+ session.usageStats.contextWindowSize = statusData.context_window_size;
15569
+ session.usageStats.lastUpdated = new Date(statusData.timestamp);
15570
+ if (statusData.model) {
15571
+ session.usageStats.primaryModel = statusData.model.id;
15572
+ session.usageStats.modelDisplayName = statusData.model.display_name;
15573
+ }
15574
+ if (statusData.cost) {
15575
+ session.usageStats.totalCostUSD = statusData.cost.total_cost_usd;
15576
+ }
15577
+ const contextPct = session.usageStats.contextWindowSize > 0 ? Math.round(contextTokens / session.usageStats.contextWindowSize * 100) : 0;
15578
+ log6.debug(`Updated from status line: context ${contextTokens}/${session.usageStats.contextWindowSize} (${contextPct}%)`);
15579
+ }
15580
+ }
15512
15581
 
15513
15582
  // src/session/reactions.ts
15514
15583
  var log7 = createLogger("reactions");
@@ -15660,6 +15729,7 @@ import { spawn } from "child_process";
15660
15729
  import { EventEmitter as EventEmitter2 } from "events";
15661
15730
  import { resolve as resolve2, dirname as dirname2 } from "path";
15662
15731
  import { fileURLToPath } from "url";
15732
+ import { existsSync as existsSync4, readFileSync as readFileSync4, watchFile, unwatchFile, unlinkSync } from "fs";
15663
15733
  var log8 = createLogger("claude");
15664
15734
 
15665
15735
  class ClaudeCli extends EventEmitter2 {
@@ -15667,10 +15737,48 @@ class ClaudeCli extends EventEmitter2 {
15667
15737
  options;
15668
15738
  buffer = "";
15669
15739
  debug = process.env.DEBUG === "1" || process.argv.includes("--debug");
15740
+ statusFilePath = null;
15741
+ lastStatusData = null;
15670
15742
  constructor(options) {
15671
15743
  super();
15672
15744
  this.options = options;
15673
15745
  }
15746
+ getStatusFilePath() {
15747
+ return this.statusFilePath;
15748
+ }
15749
+ getStatusData() {
15750
+ if (!this.statusFilePath)
15751
+ return null;
15752
+ try {
15753
+ if (existsSync4(this.statusFilePath)) {
15754
+ const data = readFileSync4(this.statusFilePath, "utf8");
15755
+ this.lastStatusData = JSON.parse(data);
15756
+ }
15757
+ } catch {}
15758
+ return this.lastStatusData;
15759
+ }
15760
+ startStatusWatch() {
15761
+ if (!this.statusFilePath)
15762
+ return;
15763
+ const checkStatus = () => {
15764
+ const data = this.getStatusData();
15765
+ if (data && data.timestamp !== this.lastStatusData?.timestamp) {
15766
+ this.lastStatusData = data;
15767
+ this.emit("status", data);
15768
+ }
15769
+ };
15770
+ watchFile(this.statusFilePath, { interval: 1000 }, checkStatus);
15771
+ }
15772
+ stopStatusWatch() {
15773
+ if (this.statusFilePath) {
15774
+ unwatchFile(this.statusFilePath);
15775
+ try {
15776
+ if (existsSync4(this.statusFilePath)) {
15777
+ unlinkSync(this.statusFilePath);
15778
+ }
15779
+ } catch {}
15780
+ }
15781
+ }
15674
15782
  start() {
15675
15783
  if (this.process)
15676
15784
  throw new Error("Already running");
@@ -15725,6 +15833,18 @@ class ClaudeCli extends EventEmitter2 {
15725
15833
  if (this.options.appendSystemPrompt) {
15726
15834
  args.push("--append-system-prompt", this.options.appendSystemPrompt);
15727
15835
  }
15836
+ if (this.options.sessionId) {
15837
+ this.statusFilePath = `/tmp/claude-threads-status-${this.options.sessionId}.json`;
15838
+ const statusLineWriterPath = this.getStatusLineWriterPath();
15839
+ const statusLineSettings = {
15840
+ statusLine: {
15841
+ type: "command",
15842
+ command: `node ${statusLineWriterPath} ${this.options.sessionId}`,
15843
+ padding: 0
15844
+ }
15845
+ };
15846
+ args.push("--settings", JSON.stringify(statusLineSettings));
15847
+ }
15728
15848
  log8.debug(`Starting: ${claudePath} ${args.slice(0, 5).join(" ")}...`);
15729
15849
  this.process = spawn(claudePath, args, {
15730
15850
  cwd: this.options.workingDir,
@@ -15800,6 +15920,7 @@ class ClaudeCli extends EventEmitter2 {
15800
15920
  return this.process !== null;
15801
15921
  }
15802
15922
  kill() {
15923
+ this.stopStatusWatch();
15803
15924
  this.process?.kill("SIGTERM");
15804
15925
  this.process = null;
15805
15926
  }
@@ -15814,12 +15935,17 @@ class ClaudeCli extends EventEmitter2 {
15814
15935
  const __dirname2 = dirname2(__filename2);
15815
15936
  return resolve2(__dirname2, "..", "mcp", "permission-server.js");
15816
15937
  }
15938
+ getStatusLineWriterPath() {
15939
+ const __filename2 = fileURLToPath(import.meta.url);
15940
+ const __dirname2 = dirname2(__filename2);
15941
+ return resolve2(__dirname2, "..", "statusline", "writer.js");
15942
+ }
15817
15943
  }
15818
15944
 
15819
15945
  // src/session/commands.ts
15820
- import { randomUUID } from "crypto";
15946
+ import { randomUUID as randomUUID2 } from "crypto";
15821
15947
  import { resolve as resolve5 } from "path";
15822
- import { existsSync as existsSync6, statSync } from "fs";
15948
+ import { existsSync as existsSync7, statSync } from "fs";
15823
15949
 
15824
15950
  // node_modules/update-notifier/update-notifier.js
15825
15951
  import process10 from "process";
@@ -19110,7 +19236,7 @@ function updateNotifier(options) {
19110
19236
  var import_semver2 = __toESM(require_semver2(), 1);
19111
19237
 
19112
19238
  // src/version.ts
19113
- import { readFileSync as readFileSync4, existsSync as existsSync4 } from "fs";
19239
+ import { readFileSync as readFileSync5, existsSync as existsSync5 } from "fs";
19114
19240
  import { dirname as dirname3, resolve as resolve3 } from "path";
19115
19241
  import { fileURLToPath as fileURLToPath4 } from "url";
19116
19242
  var __dirname4 = dirname3(fileURLToPath4(import.meta.url));
@@ -19121,9 +19247,9 @@ function loadPackageJson() {
19121
19247
  resolve3(process.cwd(), "package.json")
19122
19248
  ];
19123
19249
  for (const candidate of candidates) {
19124
- if (existsSync4(candidate)) {
19250
+ if (existsSync5(candidate)) {
19125
19251
  try {
19126
- const pkg = JSON.parse(readFileSync4(candidate, "utf-8"));
19252
+ const pkg = JSON.parse(readFileSync5(candidate, "utf-8"));
19127
19253
  if (pkg.name === "claude-threads") {
19128
19254
  return { version: pkg.version, name: pkg.name };
19129
19255
  }
@@ -19166,7 +19292,7 @@ function getUpdateInfo() {
19166
19292
  }
19167
19293
 
19168
19294
  // src/changelog.ts
19169
- import { readFileSync as readFileSync5, existsSync as existsSync5 } from "fs";
19295
+ import { readFileSync as readFileSync6, existsSync as existsSync6 } from "fs";
19170
19296
  import { dirname as dirname4, resolve as resolve4 } from "path";
19171
19297
  import { fileURLToPath as fileURLToPath5 } from "url";
19172
19298
  var __dirname5 = dirname4(fileURLToPath5(import.meta.url));
@@ -19177,7 +19303,7 @@ function getReleaseNotes(version) {
19177
19303
  ];
19178
19304
  let changelogPath = null;
19179
19305
  for (const p of possiblePaths) {
19180
- if (existsSync5(p)) {
19306
+ if (existsSync6(p)) {
19181
19307
  changelogPath = p;
19182
19308
  break;
19183
19309
  }
@@ -19186,7 +19312,7 @@ function getReleaseNotes(version) {
19186
19312
  return null;
19187
19313
  }
19188
19314
  try {
19189
- const content = readFileSync5(changelogPath, "utf-8");
19315
+ const content = readFileSync6(changelogPath, "utf-8");
19190
19316
  return parseChangelog(content, version);
19191
19317
  } catch {
19192
19318
  return null;
@@ -19604,6 +19730,170 @@ async function postUser(session, message) {
19604
19730
  return session.platform.createPost(`\uD83D\uDC64 ${message}`, session.threadId);
19605
19731
  }
19606
19732
 
19733
+ // src/git/worktree.ts
19734
+ import { spawn as spawn4 } from "child_process";
19735
+ import { randomUUID } from "crypto";
19736
+ import * as path9 from "path";
19737
+ import * as fs5 from "fs/promises";
19738
+ async function execGit(args, cwd) {
19739
+ return new Promise((resolve5, reject) => {
19740
+ const proc = spawn4("git", args, { cwd });
19741
+ let stdout = "";
19742
+ let stderr = "";
19743
+ proc.stdout.on("data", (data) => {
19744
+ stdout += data.toString();
19745
+ });
19746
+ proc.stderr.on("data", (data) => {
19747
+ stderr += data.toString();
19748
+ });
19749
+ proc.on("close", (code) => {
19750
+ if (code === 0) {
19751
+ resolve5(stdout.trim());
19752
+ } else {
19753
+ reject(new Error(`git ${args.join(" ")} failed: ${stderr || stdout}`));
19754
+ }
19755
+ });
19756
+ proc.on("error", (err) => {
19757
+ reject(err);
19758
+ });
19759
+ });
19760
+ }
19761
+ async function isGitRepository(dir) {
19762
+ try {
19763
+ await execGit(["rev-parse", "--git-dir"], dir);
19764
+ return true;
19765
+ } catch {
19766
+ return false;
19767
+ }
19768
+ }
19769
+ async function getRepositoryRoot(dir) {
19770
+ return execGit(["rev-parse", "--show-toplevel"], dir);
19771
+ }
19772
+ async function getCurrentBranch(dir) {
19773
+ try {
19774
+ const branch = await execGit(["rev-parse", "--abbrev-ref", "HEAD"], dir);
19775
+ return branch === "HEAD" ? null : branch;
19776
+ } catch {
19777
+ return null;
19778
+ }
19779
+ }
19780
+ async function hasUncommittedChanges(dir) {
19781
+ try {
19782
+ const staged = await execGit(["diff", "--cached", "--quiet"], dir).catch(() => "changes");
19783
+ if (staged === "changes")
19784
+ return true;
19785
+ const unstaged = await execGit(["diff", "--quiet"], dir).catch(() => "changes");
19786
+ if (unstaged === "changes")
19787
+ return true;
19788
+ const untracked = await execGit(["ls-files", "--others", "--exclude-standard"], dir);
19789
+ return untracked.length > 0;
19790
+ } catch {
19791
+ return false;
19792
+ }
19793
+ }
19794
+ async function listWorktrees(repoRoot) {
19795
+ const output = await execGit(["worktree", "list", "--porcelain"], repoRoot);
19796
+ const worktrees = [];
19797
+ if (!output)
19798
+ return worktrees;
19799
+ const blocks = output.split(`
19800
+
19801
+ `).filter(Boolean);
19802
+ for (const block of blocks) {
19803
+ const lines = block.split(`
19804
+ `);
19805
+ const worktree = {};
19806
+ for (const line of lines) {
19807
+ if (line.startsWith("worktree ")) {
19808
+ worktree.path = line.slice(9);
19809
+ } else if (line.startsWith("HEAD ")) {
19810
+ worktree.commit = line.slice(5);
19811
+ } else if (line.startsWith("branch ")) {
19812
+ worktree.branch = line.slice(7).replace("refs/heads/", "");
19813
+ } else if (line === "bare") {
19814
+ worktree.isBare = true;
19815
+ } else if (line === "detached") {
19816
+ worktree.branch = "(detached)";
19817
+ }
19818
+ }
19819
+ if (worktree.path) {
19820
+ worktrees.push({
19821
+ path: worktree.path,
19822
+ branch: worktree.branch || "(unknown)",
19823
+ commit: worktree.commit || "",
19824
+ isMain: worktrees.length === 0,
19825
+ isBare: worktree.isBare || false
19826
+ });
19827
+ }
19828
+ }
19829
+ return worktrees;
19830
+ }
19831
+ async function branchExists(repoRoot, branch) {
19832
+ try {
19833
+ await execGit(["rev-parse", "--verify", `refs/heads/${branch}`], repoRoot);
19834
+ return true;
19835
+ } catch {
19836
+ try {
19837
+ await execGit(["rev-parse", "--verify", `refs/remotes/origin/${branch}`], repoRoot);
19838
+ return true;
19839
+ } catch {
19840
+ return false;
19841
+ }
19842
+ }
19843
+ }
19844
+ function getWorktreeDir(repoRoot, branch) {
19845
+ const repoName = path9.basename(repoRoot);
19846
+ const parentDir = path9.dirname(repoRoot);
19847
+ const worktreesDir = path9.join(parentDir, `${repoName}-worktrees`);
19848
+ const sanitizedBranch = branch.replace(/\//g, "-").replace(/[^a-zA-Z0-9-_]/g, "");
19849
+ const shortUuid = randomUUID().slice(0, 8);
19850
+ return path9.join(worktreesDir, `${sanitizedBranch}-${shortUuid}`);
19851
+ }
19852
+ async function createWorktree(repoRoot, branch, targetDir) {
19853
+ const parentDir = path9.dirname(targetDir);
19854
+ await fs5.mkdir(parentDir, { recursive: true });
19855
+ const exists = await branchExists(repoRoot, branch);
19856
+ if (exists) {
19857
+ await execGit(["worktree", "add", targetDir, branch], repoRoot);
19858
+ } else {
19859
+ await execGit(["worktree", "add", "-b", branch, targetDir], repoRoot);
19860
+ }
19861
+ return targetDir;
19862
+ }
19863
+ async function removeWorktree(repoRoot, worktreePath) {
19864
+ try {
19865
+ await execGit(["worktree", "remove", worktreePath], repoRoot);
19866
+ } catch {
19867
+ await execGit(["worktree", "remove", "--force", worktreePath], repoRoot);
19868
+ }
19869
+ await execGit(["worktree", "prune"], repoRoot);
19870
+ }
19871
+ async function findWorktreeByBranch(repoRoot, branch) {
19872
+ const worktrees = await listWorktrees(repoRoot);
19873
+ return worktrees.find((wt) => wt.branch === branch) || null;
19874
+ }
19875
+ function isValidBranchName(name) {
19876
+ if (!name || name.length === 0)
19877
+ return false;
19878
+ if (name.startsWith("/") || name.endsWith("/"))
19879
+ return false;
19880
+ if (name.includes(".."))
19881
+ return false;
19882
+ if (/[\s~^:?*[\]\\]/.test(name))
19883
+ return false;
19884
+ if (name.startsWith("-"))
19885
+ return false;
19886
+ if (name.endsWith(".lock"))
19887
+ return false;
19888
+ if (name.includes("@{"))
19889
+ return false;
19890
+ if (name === "@")
19891
+ return false;
19892
+ if (/\.\./.test(name))
19893
+ return false;
19894
+ return true;
19895
+ }
19896
+
19607
19897
  // src/session/commands.ts
19608
19898
  var log9 = createLogger("commands");
19609
19899
  async function restartClaudeSession(session, cliOptions, ctx, actionName) {
@@ -19675,7 +19965,7 @@ async function changeDirectory(session, newDir, username, ctx) {
19675
19965
  }
19676
19966
  const expandedDir = newDir.startsWith("~") ? newDir.replace("~", process.env.HOME || "") : newDir;
19677
19967
  const absoluteDir = resolve5(expandedDir);
19678
- if (!existsSync6(absoluteDir)) {
19968
+ if (!existsSync7(absoluteDir)) {
19679
19969
  await postError(session, `Directory does not exist: \`${newDir}\``);
19680
19970
  return;
19681
19971
  }
@@ -19687,7 +19977,7 @@ async function changeDirectory(session, newDir, username, ctx) {
19687
19977
  const shortDir = absoluteDir.replace(process.env.HOME || "", "~");
19688
19978
  log9.info(`\uD83D\uDCC2 Session (${shortId}\u2026) changing directory to ${shortDir}`);
19689
19979
  session.workingDir = absoluteDir;
19690
- const newSessionId = randomUUID();
19980
+ const newSessionId = randomUUID2();
19691
19981
  session.claudeSessionId = newSessionId;
19692
19982
  const cliOptions = {
19693
19983
  workingDir: absoluteDir,
@@ -19846,6 +20136,14 @@ async function updateSessionHeader(session, ctx) {
19846
20136
  if (session.worktreeInfo) {
19847
20137
  const shortRepoRoot = session.worktreeInfo.repoRoot.replace(process.env.HOME || "", "~");
19848
20138
  rows.push(`| \uD83C\uDF3F **Worktree** | \`${session.worktreeInfo.branch}\` (from \`${shortRepoRoot}\`) |`);
20139
+ } else {
20140
+ const isRepo = await isGitRepository(session.workingDir);
20141
+ if (isRepo) {
20142
+ const branch = await getCurrentBranch(session.workingDir);
20143
+ if (branch) {
20144
+ rows.push(`| \uD83C\uDF3F **Branch** | \`${branch}\` |`);
20145
+ }
20146
+ }
19849
20147
  }
19850
20148
  if (session.pullRequestUrl) {
19851
20149
  rows.push(`| \uD83D\uDD17 **Pull Request** | ${formatPullRequestLink(session.pullRequestUrl)} |`);
@@ -19879,8 +20177,8 @@ async function updateSessionHeader(session, ctx) {
19879
20177
  }
19880
20178
 
19881
20179
  // src/session/lifecycle.ts
19882
- import { randomUUID as randomUUID2 } from "crypto";
19883
- import { existsSync as existsSync7 } from "fs";
20180
+ import { randomUUID as randomUUID3 } from "crypto";
20181
+ import { existsSync as existsSync8 } from "fs";
19884
20182
  var log10 = createLogger("lifecycle");
19885
20183
  function mutableSessions(ctx) {
19886
20184
  return ctx.state.sessions;
@@ -20003,7 +20301,7 @@ async function startSession(options, username, displayName, replyToPostId, platf
20003
20301
  return;
20004
20302
  const actualThreadId = replyToPostId || post.id;
20005
20303
  const sessionId = ctx.ops.getSessionId(platformId, actualThreadId);
20006
- const claudeSessionId = randomUUID2();
20304
+ const claudeSessionId = randomUUID3();
20007
20305
  const platformMcpConfig = platform.getMcpConfig();
20008
20306
  const cliOptions = {
20009
20307
  workingDir: ctx.config.workingDir,
@@ -20112,7 +20410,7 @@ async function resumeSession(state, ctx) {
20112
20410
  log10.warn(`Max sessions reached, skipping resume for ${shortId}...`);
20113
20411
  return;
20114
20412
  }
20115
- if (!existsSync7(state.workingDir)) {
20413
+ if (!existsSync8(state.workingDir)) {
20116
20414
  log10.warn(`Working directory ${state.workingDir} no longer exists, skipping resume for ${shortId}...`);
20117
20415
  ctx.state.sessionStore.remove(`${state.platformId}:${state.threadId}`);
20118
20416
  await withErrorHandling(() => platform.createPost(`\u26A0\uFE0F **Cannot resume session** - working directory no longer exists:
@@ -20371,162 +20669,6 @@ async function cleanupIdleSessions(timeoutMs, warningMs, ctx) {
20371
20669
  }
20372
20670
  }
20373
20671
 
20374
- // src/git/worktree.ts
20375
- import { spawn as spawn4 } from "child_process";
20376
- import { randomUUID as randomUUID3 } from "crypto";
20377
- import * as path9 from "path";
20378
- import * as fs5 from "fs/promises";
20379
- async function execGit(args, cwd) {
20380
- return new Promise((resolve6, reject) => {
20381
- const proc = spawn4("git", args, { cwd });
20382
- let stdout = "";
20383
- let stderr = "";
20384
- proc.stdout.on("data", (data) => {
20385
- stdout += data.toString();
20386
- });
20387
- proc.stderr.on("data", (data) => {
20388
- stderr += data.toString();
20389
- });
20390
- proc.on("close", (code) => {
20391
- if (code === 0) {
20392
- resolve6(stdout.trim());
20393
- } else {
20394
- reject(new Error(`git ${args.join(" ")} failed: ${stderr || stdout}`));
20395
- }
20396
- });
20397
- proc.on("error", (err) => {
20398
- reject(err);
20399
- });
20400
- });
20401
- }
20402
- async function isGitRepository(dir) {
20403
- try {
20404
- await execGit(["rev-parse", "--git-dir"], dir);
20405
- return true;
20406
- } catch {
20407
- return false;
20408
- }
20409
- }
20410
- async function getRepositoryRoot(dir) {
20411
- return execGit(["rev-parse", "--show-toplevel"], dir);
20412
- }
20413
- async function hasUncommittedChanges(dir) {
20414
- try {
20415
- const staged = await execGit(["diff", "--cached", "--quiet"], dir).catch(() => "changes");
20416
- if (staged === "changes")
20417
- return true;
20418
- const unstaged = await execGit(["diff", "--quiet"], dir).catch(() => "changes");
20419
- if (unstaged === "changes")
20420
- return true;
20421
- const untracked = await execGit(["ls-files", "--others", "--exclude-standard"], dir);
20422
- return untracked.length > 0;
20423
- } catch {
20424
- return false;
20425
- }
20426
- }
20427
- async function listWorktrees(repoRoot) {
20428
- const output = await execGit(["worktree", "list", "--porcelain"], repoRoot);
20429
- const worktrees = [];
20430
- if (!output)
20431
- return worktrees;
20432
- const blocks = output.split(`
20433
-
20434
- `).filter(Boolean);
20435
- for (const block of blocks) {
20436
- const lines = block.split(`
20437
- `);
20438
- const worktree = {};
20439
- for (const line of lines) {
20440
- if (line.startsWith("worktree ")) {
20441
- worktree.path = line.slice(9);
20442
- } else if (line.startsWith("HEAD ")) {
20443
- worktree.commit = line.slice(5);
20444
- } else if (line.startsWith("branch ")) {
20445
- worktree.branch = line.slice(7).replace("refs/heads/", "");
20446
- } else if (line === "bare") {
20447
- worktree.isBare = true;
20448
- } else if (line === "detached") {
20449
- worktree.branch = "(detached)";
20450
- }
20451
- }
20452
- if (worktree.path) {
20453
- worktrees.push({
20454
- path: worktree.path,
20455
- branch: worktree.branch || "(unknown)",
20456
- commit: worktree.commit || "",
20457
- isMain: worktrees.length === 0,
20458
- isBare: worktree.isBare || false
20459
- });
20460
- }
20461
- }
20462
- return worktrees;
20463
- }
20464
- async function branchExists(repoRoot, branch) {
20465
- try {
20466
- await execGit(["rev-parse", "--verify", `refs/heads/${branch}`], repoRoot);
20467
- return true;
20468
- } catch {
20469
- try {
20470
- await execGit(["rev-parse", "--verify", `refs/remotes/origin/${branch}`], repoRoot);
20471
- return true;
20472
- } catch {
20473
- return false;
20474
- }
20475
- }
20476
- }
20477
- function getWorktreeDir(repoRoot, branch) {
20478
- const repoName = path9.basename(repoRoot);
20479
- const parentDir = path9.dirname(repoRoot);
20480
- const worktreesDir = path9.join(parentDir, `${repoName}-worktrees`);
20481
- const sanitizedBranch = branch.replace(/\//g, "-").replace(/[^a-zA-Z0-9-_]/g, "");
20482
- const shortUuid = randomUUID3().slice(0, 8);
20483
- return path9.join(worktreesDir, `${sanitizedBranch}-${shortUuid}`);
20484
- }
20485
- async function createWorktree(repoRoot, branch, targetDir) {
20486
- const parentDir = path9.dirname(targetDir);
20487
- await fs5.mkdir(parentDir, { recursive: true });
20488
- const exists = await branchExists(repoRoot, branch);
20489
- if (exists) {
20490
- await execGit(["worktree", "add", targetDir, branch], repoRoot);
20491
- } else {
20492
- await execGit(["worktree", "add", "-b", branch, targetDir], repoRoot);
20493
- }
20494
- return targetDir;
20495
- }
20496
- async function removeWorktree(repoRoot, worktreePath) {
20497
- try {
20498
- await execGit(["worktree", "remove", worktreePath], repoRoot);
20499
- } catch {
20500
- await execGit(["worktree", "remove", "--force", worktreePath], repoRoot);
20501
- }
20502
- await execGit(["worktree", "prune"], repoRoot);
20503
- }
20504
- async function findWorktreeByBranch(repoRoot, branch) {
20505
- const worktrees = await listWorktrees(repoRoot);
20506
- return worktrees.find((wt) => wt.branch === branch) || null;
20507
- }
20508
- function isValidBranchName(name) {
20509
- if (!name || name.length === 0)
20510
- return false;
20511
- if (name.startsWith("/") || name.endsWith("/"))
20512
- return false;
20513
- if (name.includes(".."))
20514
- return false;
20515
- if (/[\s~^:?*[\]\\]/.test(name))
20516
- return false;
20517
- if (name.startsWith("-"))
20518
- return false;
20519
- if (name.endsWith(".lock"))
20520
- return false;
20521
- if (name.includes("@{"))
20522
- return false;
20523
- if (name === "@")
20524
- return false;
20525
- if (/\.\./.test(name))
20526
- return false;
20527
- return true;
20528
- }
20529
-
20530
20672
  // src/session/worktree.ts
20531
20673
  import { randomUUID as randomUUID4 } from "crypto";
20532
20674
  var log11 = createLogger("worktree");
@@ -21094,6 +21236,26 @@ function getSessionTopic(session) {
21094
21236
  }
21095
21237
  return formatTopicFromPrompt(session.firstPrompt);
21096
21238
  }
21239
+ function getHistorySessionTopic(session) {
21240
+ if (session.sessionTitle) {
21241
+ return session.sessionTitle;
21242
+ }
21243
+ return formatTopicFromPrompt(session.firstPrompt);
21244
+ }
21245
+ function formatHistoryEntry(session) {
21246
+ const topic = getHistorySessionTopic(session);
21247
+ const threadLink = `[${topic}](/_redirect/pl/${session.threadId})`;
21248
+ const displayName = session.startedByDisplayName || session.startedBy;
21249
+ const cleanedAt = session.cleanedAt ? new Date(session.cleanedAt) : new Date(session.lastActivityAt);
21250
+ const time = formatRelativeTime(cleanedAt);
21251
+ const prStr = session.pullRequestUrl ? ` \xB7 ${formatPullRequestLink(session.pullRequestUrl)}` : "";
21252
+ const lines = [];
21253
+ lines.push(` \u2713 ${threadLink} \xB7 **${displayName}**${prStr} \xB7 ${time}`);
21254
+ if (session.sessionDescription) {
21255
+ lines.push(` _${session.sessionDescription}_`);
21256
+ }
21257
+ return lines;
21258
+ }
21097
21259
  async function buildStatusBar(sessionCount, config) {
21098
21260
  const items = [];
21099
21261
  items.push(`\`v${VERSION}\``);
@@ -21137,17 +21299,27 @@ function formatTopicFromPrompt(prompt) {
21137
21299
  async function buildStickyMessage(sessions, platformId, config) {
21138
21300
  const platformSessions = [...sessions.values()].filter((s) => s.platformId === platformId);
21139
21301
  const statusBar = await buildStatusBar(platformSessions.length, config);
21302
+ const historySessions = sessionStore ? sessionStore.getHistory(platformId).slice(0, 5) : [];
21140
21303
  if (platformSessions.length === 0) {
21141
- return [
21304
+ const lines2 = [
21142
21305
  "---",
21143
21306
  statusBar,
21144
21307
  "",
21145
21308
  "**Active Claude Threads**",
21146
21309
  "",
21147
- "_No active sessions_",
21148
- "",
21149
- "_Mention me to start a session_ \xB7 `npm i -g claude-threads`"
21150
- ].join(`
21310
+ "_No active sessions_"
21311
+ ];
21312
+ if (historySessions.length > 0) {
21313
+ lines2.push("");
21314
+ lines2.push(`**Recent** (${historySessions.length})`);
21315
+ lines2.push("");
21316
+ for (const historySession of historySessions) {
21317
+ lines2.push(...formatHistoryEntry(historySession));
21318
+ }
21319
+ }
21320
+ lines2.push("");
21321
+ lines2.push("_Mention me to start a session_ \xB7 `npm i -g claude-threads`");
21322
+ return lines2.join(`
21151
21323
  `);
21152
21324
  }
21153
21325
  platformSessions.sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime());
@@ -21180,6 +21352,14 @@ async function buildStickyMessage(sessions, platformId, config) {
21180
21352
  lines.push(` \uD83D\uDD04 _${activeTask}_`);
21181
21353
  }
21182
21354
  }
21355
+ if (historySessions.length > 0) {
21356
+ lines.push("");
21357
+ lines.push(`**Recent** (${historySessions.length})`);
21358
+ lines.push("");
21359
+ for (const historySession of historySessions) {
21360
+ lines.push(...formatHistoryEntry(historySession));
21361
+ }
21362
+ }
21183
21363
  lines.push("");
21184
21364
  lines.push("_Mention me to start a session_ \xB7 `npm i -g claude-threads`");
21185
21365
  return lines.join(`
@@ -21613,7 +21793,7 @@ class SessionManager {
21613
21793
  this.sessionStore.save(session.sessionId, state);
21614
21794
  }
21615
21795
  unpersistSession(sessionId) {
21616
- this.sessionStore.remove(sessionId);
21796
+ this.sessionStore.softDelete(sessionId);
21617
21797
  }
21618
21798
  async updateSessionHeader(session) {
21619
21799
  await updateSessionHeader(session, this.getContext());
@@ -21640,7 +21820,11 @@ class SessionManager {
21640
21820
  }
21641
21821
  const staleIds = this.sessionStore.cleanStale(SESSION_TIMEOUT_MS * 2);
21642
21822
  if (staleIds.length > 0) {
21643
- log14.info(`\uD83E\uDDF9 Cleaned ${staleIds.length} stale session(s) from persistence`);
21823
+ log14.info(`\uD83E\uDDF9 Soft-deleted ${staleIds.length} stale session(s) (kept for history)`);
21824
+ }
21825
+ const removedCount = this.sessionStore.cleanHistory();
21826
+ if (removedCount > 0) {
21827
+ log14.info(`\uD83D\uDDD1\uFE0F Permanently removed ${removedCount} old session(s) from history`);
21644
21828
  }
21645
21829
  const persisted = this.sessionStore.load();
21646
21830
  log14.info(`\uD83D\uDCC2 Loaded ${persisted.size} session(s) from persistence`);
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ // @bun
3
+
4
+ // src/statusline/writer.ts
5
+ import { writeFileSync, mkdirSync } from "fs";
6
+ import { dirname } from "path";
7
+ var sessionId = process.argv[2];
8
+ if (!sessionId) {
9
+ console.log("");
10
+ process.exit(0);
11
+ }
12
+ var input = "";
13
+ process.stdin.setEncoding("utf8");
14
+ process.stdin.on("data", (chunk) => {
15
+ input += chunk;
16
+ });
17
+ process.stdin.on("end", () => {
18
+ try {
19
+ const data = JSON.parse(input);
20
+ const contextWindow = data.context_window;
21
+ if (contextWindow) {
22
+ const usage = contextWindow.current_usage;
23
+ const output = {
24
+ context_window_size: contextWindow.context_window_size,
25
+ total_input_tokens: contextWindow.total_input_tokens,
26
+ total_output_tokens: contextWindow.total_output_tokens,
27
+ current_usage: usage ? {
28
+ input_tokens: usage.input_tokens || 0,
29
+ output_tokens: usage.output_tokens || 0,
30
+ cache_creation_input_tokens: usage.cache_creation_input_tokens || 0,
31
+ cache_read_input_tokens: usage.cache_read_input_tokens || 0
32
+ } : null,
33
+ model: data.model ? {
34
+ id: data.model.id,
35
+ display_name: data.model.display_name
36
+ } : null,
37
+ cost: data.cost ? {
38
+ total_cost_usd: data.cost.total_cost_usd
39
+ } : null,
40
+ timestamp: Date.now()
41
+ };
42
+ const filePath = `/tmp/claude-threads-status-${sessionId}.json`;
43
+ mkdirSync(dirname(filePath), { recursive: true });
44
+ writeFileSync(filePath, JSON.stringify(output, null, 2));
45
+ }
46
+ } catch {}
47
+ console.log("");
48
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-threads",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Share Claude Code sessions live in a Mattermost channel with interactive features",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "scripts": {
12
12
  "dev": "bun --watch src/index.ts",
13
- "build": "bun build src/index.ts --outdir dist --target bun && bun build src/mcp/permission-server.ts --outdir dist/mcp --target bun",
13
+ "build": "bun build src/index.ts --outdir dist --target bun && bun build src/mcp/permission-server.ts --outdir dist/mcp --target bun && bun build src/statusline/writer.ts --outdir dist/statusline --target bun",
14
14
  "start": "bun dist/index.js",
15
15
  "test": "bun test",
16
16
  "test:watch": "bun test --watch",