chatroom-cli 1.6.5 → 1.7.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.
Files changed (2) hide show
  1. package/dist/index.js +684 -22
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -14022,29 +14022,149 @@ async function recoverAgentState(ctx) {
14022
14022
  const entries = ctx.deps.machine.listAgentEntries(ctx.machineId);
14023
14023
  if (entries.length === 0) {
14024
14024
  console.log(` No agent entries found — nothing to recover`);
14025
- return;
14026
- }
14027
- let recovered = 0;
14028
- let cleared = 0;
14029
- for (const { chatroomId, role, entry } of entries) {
14030
- const { pid, harness } = entry;
14031
- const service = ctx.agentServices.get(harness) ?? ctx.agentServices.values().next().value;
14032
- const alive = service ? service.isAlive(pid) : false;
14033
- if (alive) {
14034
- console.log(` ✅ Recovered: ${role} (PID ${pid}, harness: ${harness})`);
14035
- recovered++;
14036
- } else {
14037
- console.log(` \uD83E\uDDF9 Stale PID ${pid} for ${role} — clearing`);
14038
- await clearAgentPidEverywhere(ctx, chatroomId, role);
14039
- cleared++;
14025
+ } else {
14026
+ let recovered = 0;
14027
+ let cleared = 0;
14028
+ const chatroomIds = new Set;
14029
+ for (const { chatroomId, role, entry } of entries) {
14030
+ const { pid, harness } = entry;
14031
+ const service = ctx.agentServices.get(harness) ?? ctx.agentServices.values().next().value;
14032
+ const alive = service ? service.isAlive(pid) : false;
14033
+ if (alive) {
14034
+ console.log(` ✅ Recovered: ${role} (PID ${pid}, harness: ${harness})`);
14035
+ recovered++;
14036
+ chatroomIds.add(chatroomId);
14037
+ } else {
14038
+ console.log(` \uD83E\uDDF9 Stale PID ${pid} for ${role} — clearing`);
14039
+ await clearAgentPidEverywhere(ctx, chatroomId, role);
14040
+ cleared++;
14041
+ }
14042
+ }
14043
+ console.log(` Recovery complete: ${recovered} alive, ${cleared} stale cleared`);
14044
+ for (const chatroomId of chatroomIds) {
14045
+ try {
14046
+ const configsResult = await ctx.deps.backend.query(api.machines.getMachineAgentConfigs, {
14047
+ sessionId: ctx.sessionId,
14048
+ chatroomId
14049
+ });
14050
+ for (const config3 of configsResult.configs) {
14051
+ if (config3.machineId === ctx.machineId && config3.workingDir) {
14052
+ ctx.activeWorkingDirs.add(config3.workingDir);
14053
+ }
14054
+ }
14055
+ } catch {}
14056
+ }
14057
+ if (ctx.activeWorkingDirs.size > 0) {
14058
+ console.log(` \uD83D\uDD00 Recovered ${ctx.activeWorkingDirs.size} active working dir(s) for git tracking`);
14040
14059
  }
14041
14060
  }
14042
- console.log(` Recovery complete: ${recovered} alive, ${cleared} stale cleared`);
14043
14061
  }
14044
14062
  var init_state_recovery = __esm(() => {
14063
+ init_api3();
14045
14064
  init_shared();
14046
14065
  });
14047
14066
 
14067
+ // src/infrastructure/services/harness-spawning/rate-limiter.ts
14068
+ class SpawnRateLimiter {
14069
+ config;
14070
+ buckets = new Map;
14071
+ constructor(config3 = {}) {
14072
+ this.config = { ...DEFAULT_CONFIG, ...config3 };
14073
+ }
14074
+ tryConsume(chatroomId, reason) {
14075
+ if (reason.startsWith("user.")) {
14076
+ return { allowed: true };
14077
+ }
14078
+ const bucket = this._getOrCreateBucket(chatroomId);
14079
+ this._refill(bucket);
14080
+ if (bucket.tokens < 1) {
14081
+ const elapsed = Date.now() - bucket.lastRefillAt;
14082
+ const retryAfterMs = this.config.refillRateMs - elapsed;
14083
+ console.warn(`⚠️ [RateLimiter] Agent spawn rate-limited for chatroom ${chatroomId} (reason: ${reason}). Retry after ${retryAfterMs}ms`);
14084
+ return { allowed: false, retryAfterMs };
14085
+ }
14086
+ bucket.tokens -= 1;
14087
+ const remaining = Math.floor(bucket.tokens);
14088
+ if (remaining <= LOW_TOKEN_THRESHOLD) {
14089
+ console.warn(`⚠️ [RateLimiter] Agent spawn tokens running low for chatroom ${chatroomId} (${remaining}/${this.config.maxTokens} remaining)`);
14090
+ }
14091
+ return { allowed: true };
14092
+ }
14093
+ getStatus(chatroomId) {
14094
+ const bucket = this._getOrCreateBucket(chatroomId);
14095
+ this._refill(bucket);
14096
+ return {
14097
+ remaining: Math.floor(bucket.tokens),
14098
+ total: this.config.maxTokens
14099
+ };
14100
+ }
14101
+ _getOrCreateBucket(chatroomId) {
14102
+ if (!this.buckets.has(chatroomId)) {
14103
+ this.buckets.set(chatroomId, {
14104
+ tokens: this.config.initialTokens,
14105
+ lastRefillAt: Date.now()
14106
+ });
14107
+ }
14108
+ return this.buckets.get(chatroomId);
14109
+ }
14110
+ _refill(bucket) {
14111
+ const now = Date.now();
14112
+ const elapsed = now - bucket.lastRefillAt;
14113
+ if (elapsed >= this.config.refillRateMs) {
14114
+ const tokensToAdd = Math.floor(elapsed / this.config.refillRateMs);
14115
+ bucket.tokens = Math.min(this.config.maxTokens, bucket.tokens + tokensToAdd);
14116
+ bucket.lastRefillAt += tokensToAdd * this.config.refillRateMs;
14117
+ }
14118
+ }
14119
+ }
14120
+ var DEFAULT_CONFIG, LOW_TOKEN_THRESHOLD = 1;
14121
+ var init_rate_limiter = __esm(() => {
14122
+ DEFAULT_CONFIG = {
14123
+ maxTokens: 5,
14124
+ refillRateMs: 60000,
14125
+ initialTokens: 5
14126
+ };
14127
+ });
14128
+
14129
+ // src/infrastructure/services/harness-spawning/harness-spawning-service.ts
14130
+ class HarnessSpawningService {
14131
+ rateLimiter;
14132
+ concurrentAgents = new Map;
14133
+ constructor({ rateLimiter }) {
14134
+ this.rateLimiter = rateLimiter;
14135
+ }
14136
+ shouldAllowSpawn(chatroomId, reason) {
14137
+ const current = this.concurrentAgents.get(chatroomId) ?? 0;
14138
+ if (current >= MAX_CONCURRENT_AGENTS_PER_CHATROOM) {
14139
+ console.warn(`⚠️ [HarnessSpawningService] Concurrent agent limit reached for chatroom ${chatroomId} ` + `(${current}/${MAX_CONCURRENT_AGENTS_PER_CHATROOM} active agents). Spawn rejected.`);
14140
+ return { allowed: false };
14141
+ }
14142
+ const result = this.rateLimiter.tryConsume(chatroomId, reason);
14143
+ if (!result.allowed) {
14144
+ console.warn(`⚠️ [HarnessSpawningService] Spawn blocked by rate limiter for chatroom ${chatroomId} ` + `(reason: ${reason}).`);
14145
+ }
14146
+ return result;
14147
+ }
14148
+ recordSpawn(chatroomId) {
14149
+ const current = this.concurrentAgents.get(chatroomId) ?? 0;
14150
+ this.concurrentAgents.set(chatroomId, current + 1);
14151
+ }
14152
+ recordExit(chatroomId) {
14153
+ const current = this.concurrentAgents.get(chatroomId) ?? 0;
14154
+ const next = Math.max(0, current - 1);
14155
+ this.concurrentAgents.set(chatroomId, next);
14156
+ }
14157
+ getConcurrentCount(chatroomId) {
14158
+ return this.concurrentAgents.get(chatroomId) ?? 0;
14159
+ }
14160
+ }
14161
+ var MAX_CONCURRENT_AGENTS_PER_CHATROOM = 10;
14162
+
14163
+ // src/infrastructure/services/harness-spawning/index.ts
14164
+ var init_harness_spawning = __esm(() => {
14165
+ init_rate_limiter();
14166
+ });
14167
+
14048
14168
  // src/commands/machine/pid.ts
14049
14169
  import { createHash } from "node:crypto";
14050
14170
  import { existsSync as existsSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4, unlinkSync as unlinkSync2, mkdirSync as mkdirSync4 } from "node:fs";
@@ -14275,7 +14395,8 @@ function createDefaultDeps16() {
14275
14395
  clock: {
14276
14396
  now: () => Date.now(),
14277
14397
  delay: (ms) => new Promise((resolve2) => setTimeout(resolve2, ms))
14278
- }
14398
+ },
14399
+ spawning: new HarnessSpawningService({ rateLimiter: new SpawnRateLimiter })
14279
14400
  };
14280
14401
  }
14281
14402
  function validateAuthentication(convexUrl) {
@@ -14392,7 +14513,9 @@ async function initDaemon() {
14392
14513
  config: config3,
14393
14514
  deps,
14394
14515
  events,
14395
- agentServices
14516
+ agentServices,
14517
+ activeWorkingDirs: new Set,
14518
+ lastPushedGitState: new Map
14396
14519
  };
14397
14520
  registerEventListeners(ctx);
14398
14521
  logStartup(ctx, availableModels);
@@ -14407,6 +14530,7 @@ var init_init2 = __esm(() => {
14407
14530
  init_machine();
14408
14531
  init_intentional_stops();
14409
14532
  init_remote_agents();
14533
+ init_harness_spawning();
14410
14534
  init_error_formatting();
14411
14535
  init_version();
14412
14536
  init_pid();
@@ -14515,6 +14639,7 @@ async function executeStartAgent(ctx, args) {
14515
14639
  const { pid } = spawnResult;
14516
14640
  const msg = `Agent spawned (PID: ${pid})`;
14517
14641
  console.log(` ✅ ${msg}`);
14642
+ ctx.deps.spawning.recordSpawn(chatroomId);
14518
14643
  try {
14519
14644
  await ctx.deps.backend.mutation(api.machines.updateSpawnedAgent, {
14520
14645
  sessionId: ctx.sessionId,
@@ -14537,7 +14662,9 @@ async function executeStartAgent(ctx, args) {
14537
14662
  harness: agentHarness,
14538
14663
  model
14539
14664
  });
14665
+ ctx.activeWorkingDirs.add(workingDir);
14540
14666
  spawnResult.onExit(({ code: code2, signal }) => {
14667
+ ctx.deps.spawning.recordExit(chatroomId);
14541
14668
  const pendingReason = ctx.deps.stops.consume(chatroomId, role);
14542
14669
  const stopReason = pendingReason ?? resolveStopReason(code2, signal, false);
14543
14670
  ctx.events.emit("agent:exited", {
@@ -14582,6 +14709,12 @@ async function onRequestStartAgent(ctx, event) {
14582
14709
  console.log(`[daemon] ⏰ Skipping expired agent.requestStart for role=${event.role} (deadline passed)`);
14583
14710
  return;
14584
14711
  }
14712
+ const spawnCheck = ctx.deps.spawning.shouldAllowSpawn(event.chatroomId, event.reason);
14713
+ if (!spawnCheck.allowed) {
14714
+ const retryMsg = spawnCheck.retryAfterMs ? ` Retry after ${spawnCheck.retryAfterMs}ms.` : "";
14715
+ console.warn(`[daemon] ⚠️ Spawn suppressed for chatroom=${event.chatroomId} role=${event.role} reason=${event.reason}.${retryMsg}`);
14716
+ return;
14717
+ }
14585
14718
  await executeStartAgent(ctx, {
14586
14719
  chatroomId: event.chatroomId,
14587
14720
  role: event.role,
@@ -14695,6 +14828,514 @@ function handlePing() {
14695
14828
  return { result: "pong", failed: false };
14696
14829
  }
14697
14830
 
14831
+ // src/infrastructure/git/types.ts
14832
+ function makeGitStateKey(machineId, workingDir) {
14833
+ return `${machineId}::${workingDir}`;
14834
+ }
14835
+ var FULL_DIFF_MAX_BYTES = 500000, COMMITS_PER_PAGE = 20;
14836
+
14837
+ // src/infrastructure/git/git-reader.ts
14838
+ import { exec as exec2 } from "node:child_process";
14839
+ import { promisify as promisify2 } from "node:util";
14840
+ async function runGit(args, cwd) {
14841
+ try {
14842
+ const result = await execAsync2(`git ${args}`, {
14843
+ cwd,
14844
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_PAGER: "cat", NO_COLOR: "1" },
14845
+ maxBuffer: FULL_DIFF_MAX_BYTES + 64 * 1024
14846
+ });
14847
+ return result;
14848
+ } catch (err) {
14849
+ return { error: err };
14850
+ }
14851
+ }
14852
+ function isGitNotInstalled(message) {
14853
+ return message.includes("command not found") || message.includes("ENOENT") || message.includes("not found") || message.includes("'git' is not recognized");
14854
+ }
14855
+ function isNotAGitRepo(message) {
14856
+ return message.includes("not a git repository") || message.includes("Not a git repository");
14857
+ }
14858
+ function isPermissionDenied(message) {
14859
+ return message.includes("Permission denied") || message.includes("EACCES");
14860
+ }
14861
+ function isEmptyRepo(stderr) {
14862
+ return stderr.includes("does not have any commits yet") || stderr.includes("no commits yet") || stderr.includes("ambiguous argument 'HEAD'") || stderr.includes("unknown revision or path");
14863
+ }
14864
+ function classifyError(errMessage) {
14865
+ if (isGitNotInstalled(errMessage)) {
14866
+ return { status: "error", message: "git is not installed or not in PATH" };
14867
+ }
14868
+ if (isNotAGitRepo(errMessage)) {
14869
+ return { status: "not_found" };
14870
+ }
14871
+ if (isPermissionDenied(errMessage)) {
14872
+ return { status: "error", message: `Permission denied: ${errMessage}` };
14873
+ }
14874
+ return { status: "error", message: errMessage.trim() };
14875
+ }
14876
+ async function isGitRepo(workingDir) {
14877
+ const result = await runGit("rev-parse --git-dir", workingDir);
14878
+ if ("error" in result)
14879
+ return false;
14880
+ return result.stdout.trim().length > 0;
14881
+ }
14882
+ async function getBranch(workingDir) {
14883
+ const result = await runGit("rev-parse --abbrev-ref HEAD", workingDir);
14884
+ if ("error" in result) {
14885
+ const errMsg = result.error.message;
14886
+ if (errMsg.includes("unknown revision") || errMsg.includes("No such file or directory") || errMsg.includes("does not have any commits")) {
14887
+ return { status: "available", branch: "HEAD" };
14888
+ }
14889
+ return classifyError(errMsg);
14890
+ }
14891
+ const branch = result.stdout.trim();
14892
+ if (!branch) {
14893
+ return { status: "error", message: "git rev-parse returned empty output" };
14894
+ }
14895
+ return { status: "available", branch };
14896
+ }
14897
+ async function isDirty(workingDir) {
14898
+ const result = await runGit("status --porcelain", workingDir);
14899
+ if ("error" in result)
14900
+ return false;
14901
+ return result.stdout.trim().length > 0;
14902
+ }
14903
+ function parseDiffStatLine(statLine) {
14904
+ const filesMatch = statLine.match(/(\d+)\s+file/);
14905
+ const insertMatch = statLine.match(/(\d+)\s+insertion/);
14906
+ const deleteMatch = statLine.match(/(\d+)\s+deletion/);
14907
+ return {
14908
+ filesChanged: filesMatch ? parseInt(filesMatch[1], 10) : 0,
14909
+ insertions: insertMatch ? parseInt(insertMatch[1], 10) : 0,
14910
+ deletions: deleteMatch ? parseInt(deleteMatch[1], 10) : 0
14911
+ };
14912
+ }
14913
+ async function getDiffStat(workingDir) {
14914
+ const result = await runGit("diff HEAD --stat", workingDir);
14915
+ if ("error" in result) {
14916
+ const errMsg = result.error.message;
14917
+ if (isEmptyRepo(result.error.message)) {
14918
+ return { status: "no_commits" };
14919
+ }
14920
+ const classified = classifyError(errMsg);
14921
+ if (classified.status === "not_found")
14922
+ return { status: "not_found" };
14923
+ return classified;
14924
+ }
14925
+ const output = result.stdout;
14926
+ const stderr = result.stderr;
14927
+ if (isEmptyRepo(stderr)) {
14928
+ return { status: "no_commits" };
14929
+ }
14930
+ if (!output.trim()) {
14931
+ return {
14932
+ status: "available",
14933
+ diffStat: { filesChanged: 0, insertions: 0, deletions: 0 }
14934
+ };
14935
+ }
14936
+ const lines = output.trim().split(`
14937
+ `);
14938
+ const summaryLine = lines[lines.length - 1] ?? "";
14939
+ const diffStat = parseDiffStatLine(summaryLine);
14940
+ return { status: "available", diffStat };
14941
+ }
14942
+ async function getFullDiff(workingDir) {
14943
+ const result = await runGit("diff HEAD", workingDir);
14944
+ if ("error" in result) {
14945
+ const errMsg = result.error.message;
14946
+ if (isEmptyRepo(errMsg)) {
14947
+ return { status: "no_commits" };
14948
+ }
14949
+ const classified = classifyError(errMsg);
14950
+ if (classified.status === "not_found")
14951
+ return { status: "not_found" };
14952
+ return classified;
14953
+ }
14954
+ const stderr = result.stderr;
14955
+ if (isEmptyRepo(stderr)) {
14956
+ return { status: "no_commits" };
14957
+ }
14958
+ const raw = result.stdout;
14959
+ const byteLength2 = Buffer.byteLength(raw, "utf8");
14960
+ if (byteLength2 > FULL_DIFF_MAX_BYTES) {
14961
+ const truncated = Buffer.from(raw, "utf8").subarray(0, FULL_DIFF_MAX_BYTES).toString("utf8");
14962
+ return { status: "truncated", content: truncated, truncated: true };
14963
+ }
14964
+ return { status: "available", content: raw, truncated: false };
14965
+ }
14966
+ async function getRecentCommits(workingDir, count = 20, skip = 0) {
14967
+ const format = "%H%x00%h%x00%s%x00%an%x00%aI";
14968
+ const skipArg = skip > 0 ? ` --skip=${skip}` : "";
14969
+ const result = await runGit(`log -${count}${skipArg} --format=${format}`, workingDir);
14970
+ if ("error" in result) {
14971
+ return [];
14972
+ }
14973
+ const output = result.stdout.trim();
14974
+ if (!output)
14975
+ return [];
14976
+ const commits = [];
14977
+ for (const line of output.split(`
14978
+ `)) {
14979
+ const trimmed = line.trim();
14980
+ if (!trimmed)
14981
+ continue;
14982
+ const parts = trimmed.split("\x00");
14983
+ if (parts.length !== 5)
14984
+ continue;
14985
+ const [sha, shortSha, message, author, date] = parts;
14986
+ commits.push({ sha, shortSha, message, author, date });
14987
+ }
14988
+ return commits;
14989
+ }
14990
+ async function getCommitDetail(workingDir, sha) {
14991
+ const result = await runGit(`show ${sha} --format="" --stat -p`, workingDir);
14992
+ if ("error" in result) {
14993
+ const errMsg = result.error.message;
14994
+ const classified = classifyError(errMsg);
14995
+ if (classified.status === "not_found")
14996
+ return { status: "not_found" };
14997
+ if (isEmptyRepo(errMsg) || errMsg.includes("unknown revision") || errMsg.includes("bad object") || errMsg.includes("does not exist")) {
14998
+ return { status: "not_found" };
14999
+ }
15000
+ return classified;
15001
+ }
15002
+ const raw = result.stdout;
15003
+ const byteLength2 = Buffer.byteLength(raw, "utf8");
15004
+ if (byteLength2 > FULL_DIFF_MAX_BYTES) {
15005
+ const truncated = Buffer.from(raw, "utf8").subarray(0, FULL_DIFF_MAX_BYTES).toString("utf8");
15006
+ return { status: "truncated", content: truncated, truncated: true };
15007
+ }
15008
+ return { status: "available", content: raw, truncated: false };
15009
+ }
15010
+ async function getCommitMetadata(workingDir, sha) {
15011
+ const format = "%s%x00%an%x00%aI";
15012
+ const result = await runGit(`log -1 --format=${format} ${sha}`, workingDir);
15013
+ if ("error" in result)
15014
+ return null;
15015
+ const output = result.stdout.trim();
15016
+ if (!output)
15017
+ return null;
15018
+ const parts = output.split("\x00");
15019
+ if (parts.length !== 3)
15020
+ return null;
15021
+ return { message: parts[0], author: parts[1], date: parts[2] };
15022
+ }
15023
+ var execAsync2;
15024
+ var init_git_reader = __esm(() => {
15025
+ execAsync2 = promisify2(exec2);
15026
+ });
15027
+
15028
+ // src/commands/machine/daemon-start/git-polling.ts
15029
+ function startGitPollingLoop(ctx) {
15030
+ const timer = setInterval(() => {
15031
+ runPollingTick(ctx).catch((err) => {
15032
+ console.warn(`[${formatTimestamp()}] ⚠️ Git polling tick failed: ${err.message}`);
15033
+ });
15034
+ }, GIT_POLLING_INTERVAL_MS);
15035
+ timer.unref();
15036
+ console.log(`[${formatTimestamp()}] \uD83D\uDD00 Git polling loop started (interval: ${GIT_POLLING_INTERVAL_MS}ms)`);
15037
+ return {
15038
+ stop: () => {
15039
+ clearInterval(timer);
15040
+ console.log(`[${formatTimestamp()}] \uD83D\uDD00 Git polling loop stopped`);
15041
+ }
15042
+ };
15043
+ }
15044
+ function extractDiffStatFromShowOutput(content) {
15045
+ for (const line of content.split(`
15046
+ `)) {
15047
+ if (/\d+\s+file.*changed/.test(line)) {
15048
+ return parseDiffStatLine(line);
15049
+ }
15050
+ }
15051
+ return { filesChanged: 0, insertions: 0, deletions: 0 };
15052
+ }
15053
+ async function processFullDiff(ctx, req) {
15054
+ const result = await getFullDiff(req.workingDir);
15055
+ if (result.status === "available" || result.status === "truncated") {
15056
+ const diffStatResult = await getDiffStat(req.workingDir);
15057
+ const diffStat = diffStatResult.status === "available" ? diffStatResult.diffStat : { filesChanged: 0, insertions: 0, deletions: 0 };
15058
+ await ctx.deps.backend.mutation(api.workspaces.upsertFullDiff, {
15059
+ sessionId: ctx.sessionId,
15060
+ machineId: ctx.machineId,
15061
+ workingDir: req.workingDir,
15062
+ diffContent: result.content,
15063
+ truncated: result.truncated,
15064
+ diffStat
15065
+ });
15066
+ console.log(`[${formatTimestamp()}] \uD83D\uDCC4 Full diff pushed: ${req.workingDir} (${diffStat.filesChanged} files, ${result.truncated ? "truncated" : "complete"})`);
15067
+ } else {
15068
+ await ctx.deps.backend.mutation(api.workspaces.upsertFullDiff, {
15069
+ sessionId: ctx.sessionId,
15070
+ machineId: ctx.machineId,
15071
+ workingDir: req.workingDir,
15072
+ diffContent: "",
15073
+ truncated: false,
15074
+ diffStat: { filesChanged: 0, insertions: 0, deletions: 0 }
15075
+ });
15076
+ console.log(`[${formatTimestamp()}] \uD83D\uDCC4 Full diff pushed (empty): ${req.workingDir} (${result.status})`);
15077
+ }
15078
+ }
15079
+ async function processCommitDetail(ctx, req) {
15080
+ if (!req.sha) {
15081
+ throw new Error("commit_detail request missing sha");
15082
+ }
15083
+ const [result, metadata] = await Promise.all([
15084
+ getCommitDetail(req.workingDir, req.sha),
15085
+ getCommitMetadata(req.workingDir, req.sha)
15086
+ ]);
15087
+ if (result.status === "not_found") {
15088
+ await ctx.deps.backend.mutation(api.workspaces.upsertCommitDetail, {
15089
+ sessionId: ctx.sessionId,
15090
+ machineId: ctx.machineId,
15091
+ workingDir: req.workingDir,
15092
+ sha: req.sha,
15093
+ status: "not_found",
15094
+ message: metadata?.message,
15095
+ author: metadata?.author,
15096
+ date: metadata?.date
15097
+ });
15098
+ return;
15099
+ }
15100
+ if (result.status === "error") {
15101
+ await ctx.deps.backend.mutation(api.workspaces.upsertCommitDetail, {
15102
+ sessionId: ctx.sessionId,
15103
+ machineId: ctx.machineId,
15104
+ workingDir: req.workingDir,
15105
+ sha: req.sha,
15106
+ status: "error",
15107
+ errorMessage: result.message,
15108
+ message: metadata?.message,
15109
+ author: metadata?.author,
15110
+ date: metadata?.date
15111
+ });
15112
+ return;
15113
+ }
15114
+ const diffStat = extractDiffStatFromShowOutput(result.content);
15115
+ await ctx.deps.backend.mutation(api.workspaces.upsertCommitDetail, {
15116
+ sessionId: ctx.sessionId,
15117
+ machineId: ctx.machineId,
15118
+ workingDir: req.workingDir,
15119
+ sha: req.sha,
15120
+ status: "available",
15121
+ diffContent: result.content,
15122
+ truncated: result.truncated,
15123
+ message: metadata?.message,
15124
+ author: metadata?.author,
15125
+ date: metadata?.date,
15126
+ diffStat
15127
+ });
15128
+ console.log(`[${formatTimestamp()}] \uD83D\uDD0D Commit detail pushed: ${req.sha.slice(0, 7)} in ${req.workingDir}`);
15129
+ }
15130
+ async function processMoreCommits(ctx, req) {
15131
+ const offset = req.offset ?? 0;
15132
+ const commits = await getRecentCommits(req.workingDir, COMMITS_PER_PAGE, offset);
15133
+ const hasMoreCommits = commits.length >= COMMITS_PER_PAGE;
15134
+ await ctx.deps.backend.mutation(api.workspaces.appendMoreCommits, {
15135
+ sessionId: ctx.sessionId,
15136
+ machineId: ctx.machineId,
15137
+ workingDir: req.workingDir,
15138
+ commits,
15139
+ hasMoreCommits
15140
+ });
15141
+ console.log(`[${formatTimestamp()}] \uD83D\uDCDC More commits appended: ${req.workingDir} (+${commits.length} commits, offset=${offset})`);
15142
+ }
15143
+ async function runPollingTick(ctx) {
15144
+ const requests = await ctx.deps.backend.query(api.workspaces.getPendingRequests, {
15145
+ sessionId: ctx.sessionId,
15146
+ machineId: ctx.machineId
15147
+ });
15148
+ if (requests.length === 0)
15149
+ return;
15150
+ for (const req of requests) {
15151
+ try {
15152
+ await ctx.deps.backend.mutation(api.workspaces.updateRequestStatus, {
15153
+ sessionId: ctx.sessionId,
15154
+ requestId: req._id,
15155
+ status: "processing"
15156
+ });
15157
+ switch (req.requestType) {
15158
+ case "full_diff":
15159
+ await processFullDiff(ctx, req);
15160
+ break;
15161
+ case "commit_detail":
15162
+ await processCommitDetail(ctx, req);
15163
+ break;
15164
+ case "more_commits":
15165
+ await processMoreCommits(ctx, req);
15166
+ break;
15167
+ }
15168
+ await ctx.deps.backend.mutation(api.workspaces.updateRequestStatus, {
15169
+ sessionId: ctx.sessionId,
15170
+ requestId: req._id,
15171
+ status: "done"
15172
+ });
15173
+ } catch (err) {
15174
+ console.warn(`[${formatTimestamp()}] ⚠️ Failed to process ${req.requestType} request: ${err.message}`);
15175
+ await ctx.deps.backend.mutation(api.workspaces.updateRequestStatus, {
15176
+ sessionId: ctx.sessionId,
15177
+ requestId: req._id,
15178
+ status: "error"
15179
+ }).catch(() => {});
15180
+ }
15181
+ }
15182
+ }
15183
+ var GIT_POLLING_INTERVAL_MS = 5000;
15184
+ var init_git_polling = __esm(() => {
15185
+ init_api3();
15186
+ init_git_reader();
15187
+ });
15188
+
15189
+ // src/commands/machine/daemon-start/git-heartbeat.ts
15190
+ import { createHash as createHash2 } from "node:crypto";
15191
+ async function pushGitState(ctx) {
15192
+ if (ctx.activeWorkingDirs.size === 0)
15193
+ return;
15194
+ for (const workingDir of ctx.activeWorkingDirs) {
15195
+ try {
15196
+ await pushSingleWorkspaceGitState(ctx, workingDir);
15197
+ } catch (err) {
15198
+ console.warn(`[${formatTimestamp()}] ⚠️ Git state push failed for ${workingDir}: ${err.message}`);
15199
+ }
15200
+ }
15201
+ }
15202
+ async function pushSingleWorkspaceGitState(ctx, workingDir) {
15203
+ const stateKey = makeGitStateKey(ctx.machineId, workingDir);
15204
+ const isRepo = await isGitRepo(workingDir);
15205
+ if (!isRepo) {
15206
+ const stateHash2 = "not_found";
15207
+ if (ctx.lastPushedGitState.get(stateKey) === stateHash2)
15208
+ return;
15209
+ await ctx.deps.backend.mutation(api.workspaces.upsertWorkspaceGitState, {
15210
+ sessionId: ctx.sessionId,
15211
+ machineId: ctx.machineId,
15212
+ workingDir,
15213
+ status: "not_found"
15214
+ });
15215
+ ctx.lastPushedGitState.set(stateKey, stateHash2);
15216
+ return;
15217
+ }
15218
+ const [branchResult, dirtyResult, diffStatResult, commits] = await Promise.all([
15219
+ getBranch(workingDir),
15220
+ isDirty(workingDir),
15221
+ getDiffStat(workingDir),
15222
+ getRecentCommits(workingDir, COMMITS_PER_PAGE)
15223
+ ]);
15224
+ if (branchResult.status === "error") {
15225
+ const stateHash2 = `error:${branchResult.message}`;
15226
+ if (ctx.lastPushedGitState.get(stateKey) === stateHash2)
15227
+ return;
15228
+ await ctx.deps.backend.mutation(api.workspaces.upsertWorkspaceGitState, {
15229
+ sessionId: ctx.sessionId,
15230
+ machineId: ctx.machineId,
15231
+ workingDir,
15232
+ status: "error",
15233
+ errorMessage: branchResult.message
15234
+ });
15235
+ ctx.lastPushedGitState.set(stateKey, stateHash2);
15236
+ return;
15237
+ }
15238
+ if (branchResult.status === "not_found") {
15239
+ return;
15240
+ }
15241
+ const branch = branchResult.branch;
15242
+ const isDirty2 = dirtyResult;
15243
+ const diffStat = diffStatResult.status === "available" ? diffStatResult.diffStat : { filesChanged: 0, insertions: 0, deletions: 0 };
15244
+ const hasMoreCommits = commits.length >= COMMITS_PER_PAGE;
15245
+ const stateHash = createHash2("md5").update(JSON.stringify({ branch, isDirty: isDirty2, diffStat, shas: commits.map((c) => c.sha) })).digest("hex");
15246
+ if (ctx.lastPushedGitState.get(stateKey) === stateHash) {
15247
+ return;
15248
+ }
15249
+ await ctx.deps.backend.mutation(api.workspaces.upsertWorkspaceGitState, {
15250
+ sessionId: ctx.sessionId,
15251
+ machineId: ctx.machineId,
15252
+ workingDir,
15253
+ status: "available",
15254
+ branch,
15255
+ isDirty: isDirty2,
15256
+ diffStat,
15257
+ recentCommits: commits,
15258
+ hasMoreCommits
15259
+ });
15260
+ ctx.lastPushedGitState.set(stateKey, stateHash);
15261
+ console.log(`[${formatTimestamp()}] \uD83D\uDD00 Git state pushed: ${workingDir} (${branch}${isDirty2 ? ", dirty" : ", clean"})`);
15262
+ prefetchMissingCommitDetails(ctx, workingDir, commits).catch((err) => {
15263
+ console.warn(`[${formatTimestamp()}] ⚠️ Commit pre-fetch failed for ${workingDir}: ${err.message}`);
15264
+ });
15265
+ }
15266
+ async function prefetchMissingCommitDetails(ctx, workingDir, commits) {
15267
+ if (commits.length === 0)
15268
+ return;
15269
+ const shas = commits.map((c) => c.sha);
15270
+ const missingShas = await ctx.deps.backend.query(api.workspaces.getMissingCommitShas, {
15271
+ sessionId: ctx.sessionId,
15272
+ machineId: ctx.machineId,
15273
+ workingDir,
15274
+ shas
15275
+ });
15276
+ if (missingShas.length === 0)
15277
+ return;
15278
+ console.log(`[${formatTimestamp()}] \uD83D\uDD0D Pre-fetching ${missingShas.length} commit(s) for ${workingDir}`);
15279
+ for (const sha of missingShas) {
15280
+ try {
15281
+ await prefetchSingleCommit(ctx, workingDir, sha, commits);
15282
+ } catch (err) {
15283
+ console.warn(`[${formatTimestamp()}] ⚠️ Pre-fetch failed for ${sha.slice(0, 7)}: ${err.message}`);
15284
+ }
15285
+ }
15286
+ }
15287
+ async function prefetchSingleCommit(ctx, workingDir, sha, commits) {
15288
+ const metadata = commits.find((c) => c.sha === sha);
15289
+ const result = await getCommitDetail(workingDir, sha);
15290
+ if (result.status === "not_found") {
15291
+ await ctx.deps.backend.mutation(api.workspaces.upsertCommitDetail, {
15292
+ sessionId: ctx.sessionId,
15293
+ machineId: ctx.machineId,
15294
+ workingDir,
15295
+ sha,
15296
+ status: "not_found",
15297
+ message: metadata?.message,
15298
+ author: metadata?.author,
15299
+ date: metadata?.date
15300
+ });
15301
+ return;
15302
+ }
15303
+ if (result.status === "error") {
15304
+ await ctx.deps.backend.mutation(api.workspaces.upsertCommitDetail, {
15305
+ sessionId: ctx.sessionId,
15306
+ machineId: ctx.machineId,
15307
+ workingDir,
15308
+ sha,
15309
+ status: "error",
15310
+ errorMessage: result.message,
15311
+ message: metadata?.message,
15312
+ author: metadata?.author,
15313
+ date: metadata?.date
15314
+ });
15315
+ return;
15316
+ }
15317
+ const diffStat = extractDiffStatFromShowOutput(result.content);
15318
+ await ctx.deps.backend.mutation(api.workspaces.upsertCommitDetail, {
15319
+ sessionId: ctx.sessionId,
15320
+ machineId: ctx.machineId,
15321
+ workingDir,
15322
+ sha,
15323
+ status: "available",
15324
+ diffContent: result.content,
15325
+ truncated: result.truncated,
15326
+ message: metadata?.message,
15327
+ author: metadata?.author,
15328
+ date: metadata?.date,
15329
+ diffStat
15330
+ });
15331
+ console.log(`[${formatTimestamp()}] ✅ Pre-fetched: ${sha.slice(0, 7)} in ${workingDir}`);
15332
+ }
15333
+ var init_git_heartbeat = __esm(() => {
15334
+ init_api3();
15335
+ init_git_reader();
15336
+ init_git_polling();
15337
+ });
15338
+
14698
15339
  // src/commands/machine/daemon-start/command-loop.ts
14699
15340
  async function refreshModels(ctx) {
14700
15341
  if (!ctx.config)
@@ -14717,7 +15358,7 @@ async function refreshModels(ctx) {
14717
15358
  console.warn(`[${formatTimestamp()}] ⚠️ Model refresh failed: ${error.message}`);
14718
15359
  }
14719
15360
  }
14720
- function evictStaleDedupEntries(processedCommandIds, processedPingIds) {
15361
+ function evictStaleDedupEntries(processedCommandIds, processedPingIds, processedGitRefreshIds) {
14721
15362
  const evictBefore = Date.now() - AGENT_REQUEST_DEADLINE_MS;
14722
15363
  for (const [id, ts] of processedCommandIds) {
14723
15364
  if (ts < evictBefore)
@@ -14727,8 +15368,12 @@ function evictStaleDedupEntries(processedCommandIds, processedPingIds) {
14727
15368
  if (ts < evictBefore)
14728
15369
  processedPingIds.delete(id);
14729
15370
  }
15371
+ for (const [id, ts] of processedGitRefreshIds) {
15372
+ if (ts < evictBefore)
15373
+ processedGitRefreshIds.delete(id);
15374
+ }
14730
15375
  }
14731
- async function dispatchCommandEvent(ctx, event, processedCommandIds, processedPingIds) {
15376
+ async function dispatchCommandEvent(ctx, event, processedCommandIds, processedPingIds, processedGitRefreshIds) {
14732
15377
  const eventId = event._id.toString();
14733
15378
  if (event.type === "agent.requestStart") {
14734
15379
  if (processedCommandIds.has(eventId))
@@ -14750,6 +15395,14 @@ async function dispatchCommandEvent(ctx, event, processedCommandIds, processedPi
14750
15395
  machineId: ctx.machineId,
14751
15396
  pingEventId: event._id
14752
15397
  });
15398
+ } else if (event.type === "daemon.gitRefresh") {
15399
+ if (processedGitRefreshIds.has(eventId))
15400
+ return;
15401
+ processedGitRefreshIds.set(eventId, Date.now());
15402
+ const stateKey = makeGitStateKey(ctx.machineId, event.workingDir);
15403
+ ctx.lastPushedGitState.delete(stateKey);
15404
+ console.log(`[${formatTimestamp()}] \uD83D\uDD04 Git refresh requested for ${event.workingDir}`);
15405
+ await pushGitState(ctx);
14753
15406
  }
14754
15407
  }
14755
15408
  async function startCommandLoop(ctx) {
@@ -14761,15 +15414,21 @@ async function startCommandLoop(ctx) {
14761
15414
  }).then(() => {
14762
15415
  heartbeatCount++;
14763
15416
  console.log(`[${formatTimestamp()}] \uD83D\uDC93 Daemon heartbeat #${heartbeatCount} OK`);
15417
+ pushGitState(ctx).catch((err) => {
15418
+ console.warn(`[${formatTimestamp()}] ⚠️ Git state push failed: ${err.message}`);
15419
+ });
14764
15420
  }).catch((err) => {
14765
15421
  console.warn(`[${formatTimestamp()}] ⚠️ Daemon heartbeat failed: ${err.message}`);
14766
15422
  });
14767
15423
  }, DAEMON_HEARTBEAT_INTERVAL_MS);
14768
15424
  heartbeatTimer.unref();
15425
+ const gitPollingHandle = startGitPollingLoop(ctx);
15426
+ pushGitState(ctx).catch(() => {});
14769
15427
  const shutdown = async () => {
14770
15428
  console.log(`
14771
15429
  [${formatTimestamp()}] Shutting down...`);
14772
15430
  clearInterval(heartbeatTimer);
15431
+ gitPollingHandle.stop();
14773
15432
  await onDaemonShutdown(ctx);
14774
15433
  releaseLock();
14775
15434
  process.exit(0);
@@ -14784,17 +15443,18 @@ Listening for commands...`);
14784
15443
  `);
14785
15444
  const processedCommandIds = new Map;
14786
15445
  const processedPingIds = new Map;
15446
+ const processedGitRefreshIds = new Map;
14787
15447
  wsClient2.onUpdate(api.machines.getCommandEvents, {
14788
15448
  sessionId: ctx.sessionId,
14789
15449
  machineId: ctx.machineId
14790
15450
  }, async (result) => {
14791
15451
  if (!result.events || result.events.length === 0)
14792
15452
  return;
14793
- evictStaleDedupEntries(processedCommandIds, processedPingIds);
15453
+ evictStaleDedupEntries(processedCommandIds, processedPingIds, processedGitRefreshIds);
14794
15454
  for (const event of result.events) {
14795
15455
  try {
14796
15456
  console.log(`[${formatTimestamp()}] \uD83D\uDCE1 Stream command event: ${event.type}`);
14797
- await dispatchCommandEvent(ctx, event, processedCommandIds, processedPingIds);
15457
+ await dispatchCommandEvent(ctx, event, processedCommandIds, processedPingIds, processedGitRefreshIds);
14798
15458
  } catch (err) {
14799
15459
  console.error(`[${formatTimestamp()}] ❌ Stream command event failed: ${err.message}`);
14800
15460
  }
@@ -14818,6 +15478,8 @@ var init_command_loop = __esm(() => {
14818
15478
  init_on_request_start_agent();
14819
15479
  init_on_request_stop_agent();
14820
15480
  init_pid();
15481
+ init_git_polling();
15482
+ init_git_heartbeat();
14821
15483
  MODEL_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
14822
15484
  });
14823
15485
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatroom-cli",
3
- "version": "1.6.5",
3
+ "version": "1.7.0",
4
4
  "description": "CLI for multi-agent chatroom collaboration",
5
5
  "type": "module",
6
6
  "bin": {