@keyou007/agent-bot 0.1.15-alpha.4 → 0.1.15-alpha.5

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.
@@ -12,6 +12,7 @@ import { generateGroupAvatarPng, resolveGroupAvatarProjectName } from "../feishu
12
12
  import { normalizeFeishuPostText } from "../feishu/InboundText.js";
13
13
  import { allowsFeishuUser } from "../feishu/ownerAccess.js";
14
14
  import { buildTurnGraphRows } from "../presentation/turnGraph.js";
15
+ import { isActiveShellCommandJob, ShellCommandJobManager, } from "../shell/ShellCommandJobManager.js";
15
16
  import { createId } from "../utils/id.js";
16
17
  import { truncateMiddle, truncateText } from "../utils/markdown.js";
17
18
  import { normalizeTaskTitle } from "../utils/taskTitle.js";
@@ -27,7 +28,8 @@ const RESET_HISTORY_PAGE_SIZE = 10;
27
28
  const MAX_LLM_TURN_RETRIES = 3;
28
29
  const RECOVERY_ACTIVITY_WINDOW_MS = 5 * 60 * 1_000;
29
30
  const RECOVERY_HEARTBEAT_INTERVAL_MS = 60 * 1_000;
30
- const SHELL_COMMAND_ELAPSED_UPDATE_INTERVAL_MS = 3_000;
31
+ const SHELL_COMMAND_JOB_POLL_INTERVAL_MS = 1_000;
32
+ const SHELL_COMMAND_JOB_RELAUNCH_DELAY_MS = 5_000;
31
33
  const FORWARD_ATTACHMENT_WINDOW_MS = 800;
32
34
  const DEFAULT_MERGED_FORWARD_INSTRUCTION = "请参考以下内容回复用户";
33
35
  const DEFAULT_REFERENCED_MESSAGE_INSTRUCTION = "请参考引用消息回复用户";
@@ -61,6 +63,12 @@ const HELP_COMMAND_SECTIONS = [
61
63
  usage: "[目录]",
62
64
  description: "浏览、发送文件,并从选定目录创建任务或群",
63
65
  },
66
+ {
67
+ command: "/file",
68
+ usage: "<文件路径>",
69
+ description: "将指定文件发送到当前飞书会话",
70
+ requiresArgument: true,
71
+ },
64
72
  {
65
73
  command: "/forkgroup",
66
74
  usage: "[title]",
@@ -170,6 +178,7 @@ export class ProxySessionController {
170
178
  lifecycle;
171
179
  shellCommandExecutor;
172
180
  windowsDriveLister;
181
+ shellCommandJobs;
173
182
  router = new CommandRouter();
174
183
  cardRenderer = new CardRenderer();
175
184
  messageQueues = new Map();
@@ -186,11 +195,12 @@ export class ProxySessionController {
186
195
  pendingMergedForwards = new Map();
187
196
  pendingResourceForwards = new Map();
188
197
  relatedReactionMessageIds = new Map();
198
+ shellCommandJobMonitors = new Map();
189
199
  unsubscribe = [];
190
200
  recoveryActivityHeartbeat;
191
201
  startupRecovery;
192
202
  queuedPromptsRestored = false;
193
- constructor(config, store, runtimes, outbound, logger, lifecycle, shellCommandExecutor = executeShellCommand, windowsDriveLister = listWindowsDriveRoots) {
203
+ constructor(config, store, runtimes, outbound, logger, lifecycle, shellCommandExecutor = executeShellCommand, windowsDriveLister = listWindowsDriveRoots, shellCommandJobs = new ShellCommandJobManager(path.join(path.dirname(config.storage.sqlitePath), "command-jobs"))) {
194
204
  this.config = config;
195
205
  this.store = store;
196
206
  this.runtimes = runtimes;
@@ -199,6 +209,7 @@ export class ProxySessionController {
199
209
  this.lifecycle = lifecycle;
200
210
  this.shellCommandExecutor = shellCommandExecutor;
201
211
  this.windowsDriveLister = windowsDriveLister;
212
+ this.shellCommandJobs = shellCommandJobs;
202
213
  for (const [, runtime] of this.runtimes.entries()) {
203
214
  this.unsubscribe.push(runtime.onEvent((event) => {
204
215
  void this.handleRuntimeEvent(event).catch((error) => {
@@ -424,8 +435,9 @@ export class ProxySessionController {
424
435
  if (command.type === "prompt")
425
436
  await this.ensureThreadFork(message);
426
437
  await this.execute(message.contextKey, command, message.messageId, replyTarget, localImagePaths, message.userId, message);
427
- if (!isPromptCommand(command))
438
+ if (!commandDefersReactionFinalization(command)) {
428
439
  await this.finalizeStandaloneMessageReaction(message.messageId, "completed");
440
+ }
429
441
  }
430
442
  catch (error) {
431
443
  await this.finalizeStandaloneMessageReaction(message.messageId, "failed");
@@ -440,8 +452,9 @@ export class ProxySessionController {
440
452
  if (command.type === "prompt")
441
453
  await this.ensureThreadFork(message);
442
454
  await this.execute(message.contextKey, command, message.messageId, replyTarget, localImagePaths, message.userId, message);
443
- if (!isPromptCommand(command))
455
+ if (!commandDefersReactionFinalization(command)) {
444
456
  await this.finalizeStandaloneMessageReaction(message.messageId, "completed");
457
+ }
445
458
  }
446
459
  catch (error) {
447
460
  await this.finalizeStandaloneMessageReaction(message.messageId, "failed");
@@ -493,6 +506,9 @@ export class ProxySessionController {
493
506
  const sessionId = String(scopedAction.value.sessionId ?? "");
494
507
  await this.cancelSession(this.requireSession(contextKey, sessionId));
495
508
  }
509
+ else if (kind === "shell_command_cancel") {
510
+ await this.cancelShellCommandJob(contextKey, String(scopedAction.value.jobId ?? ""));
511
+ }
496
512
  else if (kind === "turn_reset") {
497
513
  const sessionId = String(scopedAction.value.sessionId ?? "");
498
514
  await this.resetCurrentSessionToTurn(contextKey, sessionId, String(scopedAction.value.turnId ?? ""));
@@ -753,6 +769,8 @@ export class ProxySessionController {
753
769
  for (const timer of this.recoveryRetryTimers.values())
754
770
  clearTimeout(timer);
755
771
  this.recoveryRetryTimers.clear();
772
+ for (const [jobId] of this.shellCommandJobMonitors)
773
+ this.stopShellCommandJobMonitor(jobId);
756
774
  clearInterval(this.recoveryActivityHeartbeat);
757
775
  }
758
776
  async controlStopTask(localSessionId) {
@@ -1269,11 +1287,14 @@ export class ProxySessionController {
1269
1287
  }
1270
1288
  switch (command.type) {
1271
1289
  case "shell":
1272
- await this.runShellCommand(contextKey, command.command);
1290
+ await this.runShellCommand(contextKey, command.command, messageId);
1273
1291
  return;
1274
1292
  case "dir":
1275
1293
  await this.openDirectoryBrowser(contextKey, command.directory);
1276
1294
  return;
1295
+ case "file":
1296
+ await this.sendCurrentTaskFile(contextKey, command.filePath);
1297
+ return;
1277
1298
  case "new":
1278
1299
  if (command.projectless && this.ensureAgent(context.defaultAgent).kind !== "app-server") {
1279
1300
  throw new Error("/new --nodir 仅支持 App Server Agent。");
@@ -1416,6 +1437,7 @@ export class ProxySessionController {
1416
1437
  return recovery;
1417
1438
  }
1418
1439
  async runStartupRecovery() {
1440
+ await this.recoverShellCommandJobs();
1419
1441
  this.backfillTurnAttemptsForUpgrade();
1420
1442
  const attempts = this.store.listIncompleteTurnAttempts();
1421
1443
  const recoveryCutoff = Date.now() - RECOVERY_ACTIVITY_WINDOW_MS;
@@ -3510,102 +3532,138 @@ export class ProxySessionController {
3510
3532
  this.logger.warn({ error, localSessionId }, "Failed to read the latest Agent Goal status.");
3511
3533
  }
3512
3534
  }
3513
- async runShellCommand(contextKey, command) {
3535
+ async runShellCommand(contextKey, command, sourceMessageId) {
3514
3536
  const cwd = this.currentSession(contextKey)?.cwd ?? this.config.defaults.cwd;
3515
- const startedAt = Date.now();
3516
- let latestOutput = {
3517
- stdout: "",
3518
- stderr: "",
3519
- outputTruncated: false,
3520
- };
3521
- let runningState = {
3537
+ const created = await this.shellCommandJobs.createJob({ contextKey, sourceMessageId, command, cwd });
3538
+ let cardMessageId;
3539
+ const initialState = {
3540
+ jobId: created.id,
3541
+ contextKey,
3522
3542
  command,
3523
3543
  cwd,
3524
- ...latestOutput,
3544
+ output: "",
3545
+ outputTruncated: false,
3525
3546
  status: "running",
3526
3547
  elapsedMs: 0,
3527
3548
  };
3528
- const cardMessageId = await this.outbound.sendInteractiveCard(contextKey, this.cardRenderer.renderShellCommandCard(runningState));
3529
- const scheduler = cardMessageId
3530
- ? new CardUpdateScheduler({
3531
- render: (state) => this.cardRenderer.renderShellCommandCard(state),
3532
- write: (card) => this.outbound.updateInteractiveCard(contextKey, cardMessageId, card),
3533
- onError: (error) => this.logger.warn({ error, contextKey, messageId: cardMessageId }, "Failed to update a shell command card."),
3534
- })
3535
- : undefined;
3536
- scheduler?.seed(runningState);
3537
- const elapsedTimer = scheduler ? setInterval(() => {
3538
- runningState = { ...runningState, elapsedMs: Date.now() - startedAt };
3539
- scheduler.update(runningState);
3540
- }, SHELL_COMMAND_ELAPSED_UPDATE_INTERVAL_MS) : undefined;
3541
- elapsedTimer?.unref?.();
3542
- const updateRunningOutput = (snapshot) => {
3543
- latestOutput = snapshot;
3544
- runningState = {
3545
- ...runningState,
3546
- ...snapshot,
3547
- elapsedMs: Date.now() - startedAt,
3548
- };
3549
- scheduler?.update(runningState);
3550
- };
3551
- let result;
3552
3549
  try {
3553
- result = await this.shellCommandExecutor(command, cwd, { onOutput: updateRunningOutput });
3550
+ cardMessageId = await this.outbound.sendInteractiveCard(contextKey, this.cardRenderer.renderShellCommandCard(initialState));
3551
+ if (!cardMessageId)
3552
+ throw new Error("Failed to create the shell command card.");
3553
+ const bound = await this.shellCommandJobs.bindCard(created.id, cardMessageId);
3554
+ await this.shellCommandJobs.startJob(created.id);
3555
+ this.monitorShellCommandJob(bound, initialState);
3554
3556
  }
3555
3557
  catch (error) {
3556
- if (elapsedTimer)
3557
- clearInterval(elapsedTimer);
3558
- const failedCard = this.cardRenderer.renderShellCommandCard({
3559
- command,
3560
- cwd,
3561
- ...latestOutput,
3562
- stderr: [latestOutput.stderr.trimEnd(), runtimeErrorMessage(error)].filter(Boolean).join("\n"),
3563
- status: "failed",
3564
- elapsedMs: Date.now() - startedAt,
3565
- });
3566
- if (scheduler) {
3558
+ await this.shellCommandJobs.failJob(created.id, runtimeErrorMessage(error)).catch(() => undefined);
3559
+ if (cardMessageId) {
3567
3560
  try {
3568
- await scheduler.flush({
3569
- ...runningState,
3570
- stderr: [latestOutput.stderr.trimEnd(), runtimeErrorMessage(error)].filter(Boolean).join("\n"),
3571
- status: "failed",
3572
- elapsedMs: Date.now() - startedAt,
3573
- });
3561
+ const failed = await this.shellCommandJobs.readJob(created.id);
3562
+ await this.outbound.updateInteractiveCard(contextKey, cardMessageId, this.cardRenderer.renderShellCommandCard(shellCommandJobCardView(failed)));
3563
+ await this.shellCommandJobs.markPresented(created.id);
3574
3564
  }
3575
3565
  catch (cardError) {
3576
- this.logger.warn({ error: cardError, contextKey, messageId: cardMessageId }, "Failed to finalize a shell command card.");
3577
- }
3578
- finally {
3579
- scheduler.dispose();
3566
+ this.logger.warn({ error: cardError, jobId: created.id, contextKey, messageId: cardMessageId }, "Failed to finalize a shell command card after the runner failed to start.");
3580
3567
  }
3581
3568
  }
3582
3569
  else {
3583
- await this.outbound.sendInteractiveCard(contextKey, failedCard);
3570
+ await this.shellCommandJobs.markPresented(created.id).catch(() => undefined);
3584
3571
  }
3585
3572
  throw error;
3586
3573
  }
3587
- if (elapsedTimer)
3588
- clearInterval(elapsedTimer);
3589
- const finalState = {
3590
- command,
3591
- cwd,
3592
- stdout: result.stdout,
3593
- stderr: result.stderr,
3594
- status: result.timedOut ? "timed_out" : result.exitCode === 0 ? "completed" : "failed",
3595
- exitCode: result.exitCode,
3596
- elapsedMs: Date.now() - startedAt,
3597
- outputTruncated: result.outputTruncated,
3574
+ }
3575
+ monitorShellCommandJob(job, seededState) {
3576
+ if (!job.cardMessageId || this.shellCommandJobMonitors.has(job.id))
3577
+ return;
3578
+ const scheduler = new CardUpdateScheduler({
3579
+ render: (state) => this.cardRenderer.renderShellCommandCard(state),
3580
+ write: (card) => this.outbound.updateInteractiveCard(job.contextKey, job.cardMessageId, card),
3581
+ onError: (error) => this.logger.warn({ error, jobId: job.id, contextKey: job.contextKey, messageId: job.cardMessageId }, "Failed to update a background shell command card."),
3582
+ });
3583
+ if (seededState)
3584
+ scheduler.seed(seededState);
3585
+ const monitor = {
3586
+ scheduler,
3587
+ refreshing: false,
3588
+ timer: setInterval(() => {
3589
+ void this.refreshShellCommandJob(job.id);
3590
+ }, SHELL_COMMAND_JOB_POLL_INTERVAL_MS),
3598
3591
  };
3599
- if (scheduler) {
3600
- try {
3601
- await scheduler.flush(finalState);
3592
+ monitor.timer.unref?.();
3593
+ this.shellCommandJobMonitors.set(job.id, monitor);
3594
+ void this.refreshShellCommandJob(job.id, seededState === undefined);
3595
+ }
3596
+ async refreshShellCommandJob(jobId, critical = false) {
3597
+ const monitor = this.shellCommandJobMonitors.get(jobId);
3598
+ if (!monitor || monitor.refreshing)
3599
+ return;
3600
+ monitor.refreshing = true;
3601
+ try {
3602
+ const job = await this.shellCommandJobs.readJob(jobId);
3603
+ const view = shellCommandJobCardView(job);
3604
+ if (isActiveShellCommandJob(job.status)) {
3605
+ monitor.scheduler.update(view, critical ? "critical" : "normal");
3606
+ return;
3602
3607
  }
3603
- finally {
3604
- scheduler.dispose();
3608
+ await monitor.scheduler.flush(view);
3609
+ if (job.sourceMessageId) {
3610
+ await this.finalizeStandaloneMessageReaction(job.sourceMessageId, job.status === "completed" ? "completed" : job.status === "cancelled" ? "cancelled" : "failed");
3605
3611
  }
3612
+ await this.shellCommandJobs.markPresented(jobId);
3613
+ this.stopShellCommandJobMonitor(jobId);
3606
3614
  }
3607
- else {
3608
- await this.outbound.sendInteractiveCard(contextKey, this.cardRenderer.renderShellCommandCard(finalState));
3615
+ catch (error) {
3616
+ this.logger.warn({ error, jobId }, "Failed to refresh a background shell command job.");
3617
+ }
3618
+ finally {
3619
+ const current = this.shellCommandJobMonitors.get(jobId);
3620
+ if (current)
3621
+ current.refreshing = false;
3622
+ }
3623
+ }
3624
+ stopShellCommandJobMonitor(jobId) {
3625
+ const monitor = this.shellCommandJobMonitors.get(jobId);
3626
+ if (!monitor)
3627
+ return;
3628
+ clearInterval(monitor.timer);
3629
+ monitor.scheduler.dispose();
3630
+ this.shellCommandJobMonitors.delete(jobId);
3631
+ }
3632
+ async cancelShellCommandJob(contextKey, jobId) {
3633
+ if (!jobId)
3634
+ throw new Error("命令任务无效,请使用最新的命令卡片。");
3635
+ const job = await this.shellCommandJobs.readJob(jobId);
3636
+ if (job.contextKey !== contextKey)
3637
+ throw new Error("该命令不属于当前会话。");
3638
+ const requested = await this.shellCommandJobs.requestCancellation(jobId);
3639
+ if (!requested) {
3640
+ await this.outbound.sendText(contextKey, "该命令已经结束。");
3641
+ return;
3642
+ }
3643
+ if (!this.shellCommandJobMonitors.has(jobId))
3644
+ this.monitorShellCommandJob(job);
3645
+ await this.refreshShellCommandJob(jobId, true);
3646
+ }
3647
+ async recoverShellCommandJobs() {
3648
+ const jobs = await this.shellCommandJobs.listRecoverableJobs();
3649
+ if (jobs.length > 0) {
3650
+ this.logger.warn({ jobIds: jobs.map((job) => job.id) }, "Recovering background shell command jobs after startup.");
3651
+ }
3652
+ for (const job of jobs) {
3653
+ if (!job.cardMessageId || !this.outbound.canRoute(job.contextKey))
3654
+ continue;
3655
+ if (job.status === "starting"
3656
+ && job.runnerPid === undefined
3657
+ && Date.now() - job.updatedAt >= SHELL_COMMAND_JOB_RELAUNCH_DELAY_MS) {
3658
+ try {
3659
+ await this.shellCommandJobs.startJob(job.id);
3660
+ }
3661
+ catch (error) {
3662
+ await this.shellCommandJobs.failJob(job.id, runtimeErrorMessage(error)).catch(() => undefined);
3663
+ }
3664
+ }
3665
+ const refreshed = await this.shellCommandJobs.readJob(job.id);
3666
+ this.monitorShellCommandJob(refreshed);
3609
3667
  }
3610
3668
  }
3611
3669
  async setTitle(contextKey, title) {
@@ -3969,6 +4027,12 @@ export class ProxySessionController {
3969
4027
  if (!stats.isFile())
3970
4028
  throw new Error(`这不是普通文件:${filePath}`);
3971
4029
  }
4030
+ async sendCurrentTaskFile(contextKey, requestedFilePath) {
4031
+ const record = this.requireCurrentSession(contextKey);
4032
+ const filePath = resolveUserPath(requestedFilePath, record.cwd);
4033
+ await this.assertSendableFile(filePath);
4034
+ await this.outbound.sendFile(contextKey, filePath);
4035
+ }
3972
4036
  async listSessions(contextKey, searchTerm, options = {}) {
3973
4037
  const context = this.store.getOrCreateUserContext(contextKey, this.config.defaults.agent);
3974
4038
  const normalizedSearch = searchTerm?.trim().toLowerCase();
@@ -5304,7 +5368,7 @@ function isBotOwnedActiveTurn(record, remote) {
5304
5368
  && record.lastTurnId === remote.lastTurnId;
5305
5369
  }
5306
5370
  function isQueueIndependentCommand(command) {
5307
- if (["archive", "dismiss", "stop", "status", "restart", "mute", "help", "sessions", "dir", "goal", "nosteer"].includes(command.type))
5371
+ if (["archive", "dismiss", "stop", "status", "restart", "mute", "help", "sessions", "dir", "file", "goal", "nosteer", "shell"].includes(command.type))
5308
5372
  return true;
5309
5373
  if (command.type === "agent")
5310
5374
  return command.agent === undefined;
@@ -5312,8 +5376,22 @@ function isQueueIndependentCommand(command) {
5312
5376
  return true;
5313
5377
  return false;
5314
5378
  }
5379
+ function shellCommandJobCardView(job) {
5380
+ const status = job.status === "starting" ? "running" : job.status;
5381
+ return {
5382
+ jobId: job.id,
5383
+ contextKey: job.contextKey,
5384
+ command: job.command,
5385
+ cwd: job.cwd,
5386
+ output: [job.output.trimEnd(), job.error].filter(Boolean).join("\n"),
5387
+ status,
5388
+ exitCode: job.exitCode,
5389
+ elapsedMs: Math.max(0, (job.completedAt ?? Date.now()) - job.startedAt),
5390
+ outputTruncated: job.outputTruncated,
5391
+ };
5392
+ }
5315
5393
  function commandRequiresCurrentSession(command) {
5316
- if (["dismiss", "stop", "title", "turns", "model", "provider", "thinking", "permissions", "nosteer"].includes(command.type)) {
5394
+ if (["dismiss", "stop", "title", "turns", "file", "model", "provider", "thinking", "permissions", "nosteer"].includes(command.type)) {
5317
5395
  return true;
5318
5396
  }
5319
5397
  if (command.type === "archive" || command.type === "fork")
@@ -5485,8 +5563,8 @@ async function listWindowsDriveRoots() {
5485
5563
  throw new Error("没有检测到可用的 Windows 磁盘。");
5486
5564
  return drives;
5487
5565
  }
5488
- function isPromptCommand(command) {
5489
- return command.type === "prompt" || command.type === "nosteer";
5566
+ function commandDefersReactionFinalization(command) {
5567
+ return command.type === "prompt" || command.type === "nosteer" || command.type === "shell";
5490
5568
  }
5491
5569
  function runtimePrompt(text, localImagePaths) {
5492
5570
  return localImagePaths?.length ? { text, localImagePaths } : text;