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

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 (39) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +7 -2
  3. package/README.zh.md +7 -2
  4. package/config.example.yaml +6 -0
  5. package/dist/commands/CommandRouter.js +7 -0
  6. package/dist/commands/CommandRouter.js.map +1 -1
  7. package/dist/config/loadConfig.js +4 -0
  8. package/dist/config/loadConfig.js.map +1 -1
  9. package/dist/config/schema.js +31 -0
  10. package/dist/config/schema.js.map +1 -1
  11. package/dist/feishu/CardRenderer.js +76 -9
  12. package/dist/feishu/CardRenderer.js.map +1 -1
  13. package/dist/feishu/FinalResponseBranding.js +1 -1
  14. package/dist/feishu/FinalResponseBranding.js.map +1 -1
  15. package/dist/feishu/GroupNameFormatter.js +193 -0
  16. package/dist/feishu/GroupNameFormatter.js.map +1 -0
  17. package/dist/proxy/ProxySessionController.js +197 -148
  18. package/dist/proxy/ProxySessionController.js.map +1 -1
  19. package/dist/shell/ShellCommandJobManager.js +264 -0
  20. package/dist/shell/ShellCommandJobManager.js.map +1 -0
  21. package/dist/shell/shellCommandRunner.js +157 -0
  22. package/dist/shell/shellCommandRunner.js.map +1 -0
  23. package/dist/utils/executeShellCommand.js +1 -1
  24. package/dist/utils/executeShellCommand.js.map +1 -1
  25. package/docs/technical-reference.md +19 -0
  26. package/docs/technical-reference.zh.md +19 -0
  27. package/npm-shrinkwrap.json +2 -2
  28. package/package.json +1 -1
  29. package/src/commands/CommandRouter.ts +6 -0
  30. package/src/commands/commandTypes.ts +1 -0
  31. package/src/config/loadConfig.ts +4 -0
  32. package/src/config/schema.ts +33 -0
  33. package/src/feishu/CardRenderer.ts +91 -17
  34. package/src/feishu/FinalResponseBranding.ts +2 -1
  35. package/src/feishu/GroupNameFormatter.ts +222 -0
  36. package/src/proxy/ProxySessionController.ts +238 -160
  37. package/src/shell/ShellCommandJobManager.ts +341 -0
  38. package/src/shell/shellCommandRunner.ts +163 -0
  39. package/src/utils/executeShellCommand.ts +1 -1
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import type { Logger } from "pino";
6
6
  import { createProjectlessWorkspace, detectProjectlessWorkspace } from "../codex/ProjectlessWorkspace.js";
7
7
  import { resolveUserPath } from "../config/paths.js";
8
- import type { AppConfig } from "../config/schema.js";
8
+ import { DEFAULT_GROUP_NAME_FORMAT, type AppConfig } from "../config/schema.js";
9
9
  import { CommandRouter } from "../commands/CommandRouter.js";
10
10
  import type { Command } from "../commands/commandTypes.js";
11
11
  import { baseChatContextKey, isThreadContextKey } from "../feishu/contextKey.js";
@@ -30,6 +30,11 @@ import {
30
30
  } from "../feishu/CardRenderer.js";
31
31
  import { CardUpdateScheduler } from "../feishu/CardUpdateScheduler.js";
32
32
  import { generateGroupAvatarPng, resolveGroupAvatarProjectName } from "../feishu/GroupAvatarGenerator.js";
33
+ import {
34
+ formatGroupNameDate,
35
+ formatNewGroupName,
36
+ parseTaskNameFromGroupName,
37
+ } from "../feishu/GroupNameFormatter.js";
33
38
  import { normalizeFeishuPostText } from "../feishu/InboundText.js";
34
39
  import { allowsFeishuUser } from "../feishu/ownerAccess.js";
35
40
  import type { OutboundRouter } from "../presentation/OutboundRouter.js";
@@ -47,6 +52,12 @@ import type {
47
52
  RuntimePrompt,
48
53
  RuntimeSession,
49
54
  } from "../runtime/types.js";
55
+ import {
56
+ isActiveShellCommandJob,
57
+ ShellCommandJobManager,
58
+ type ShellCommandJobManagerLike,
59
+ type ShellCommandJobSnapshot,
60
+ } from "../shell/ShellCommandJobManager.js";
50
61
  import {
51
62
  StateStore,
52
63
  type CardActionBinding,
@@ -63,7 +74,6 @@ import { normalizeTaskTitle } from "../utils/taskTitle.js";
63
74
  import {
64
75
  executeShellCommand,
65
76
  type ShellCommandOptions,
66
- type ShellCommandOutputSnapshot,
67
77
  type ShellCommandResult,
68
78
  } from "../utils/executeShellCommand.js";
69
79
 
@@ -94,6 +104,12 @@ interface ForwardAttachmentReservation {
94
104
  registry: Map<string, PendingForwardAttachment>;
95
105
  }
96
106
 
107
+ interface ShellCommandJobMonitor {
108
+ scheduler: CardUpdateScheduler<ShellCommandCardView>;
109
+ timer: NodeJS.Timeout;
110
+ refreshing: boolean;
111
+ }
112
+
97
113
  const MESSAGE_RECEIVED_REACTION = "OnIt";
98
114
  const MESSAGE_COMPLETED_REACTION = "DONE";
99
115
  const MESSAGE_FAILED_REACTION = "ERROR";
@@ -105,7 +121,8 @@ const RESET_HISTORY_PAGE_SIZE = 10;
105
121
  const MAX_LLM_TURN_RETRIES = 3;
106
122
  const RECOVERY_ACTIVITY_WINDOW_MS = 5 * 60 * 1_000;
107
123
  const RECOVERY_HEARTBEAT_INTERVAL_MS = 60 * 1_000;
108
- const SHELL_COMMAND_ELAPSED_UPDATE_INTERVAL_MS = 3_000;
124
+ const SHELL_COMMAND_JOB_POLL_INTERVAL_MS = 1_000;
125
+ const SHELL_COMMAND_JOB_RELAUNCH_DELAY_MS = 5_000;
109
126
  const FORWARD_ATTACHMENT_WINDOW_MS = 800;
110
127
  const DEFAULT_MERGED_FORWARD_INSTRUCTION = "请参考以下内容回复用户";
111
128
  const DEFAULT_REFERENCED_MESSAGE_INSTRUCTION = "请参考引用消息回复用户";
@@ -150,6 +167,12 @@ const HELP_COMMAND_SECTIONS: Array<{
150
167
  usage: "[目录]",
151
168
  description: "浏览、发送文件,并从选定目录创建任务或群",
152
169
  },
170
+ {
171
+ command: "/file",
172
+ usage: "&#60;文件路径&#62;",
173
+ description: "将指定文件发送到当前飞书会话",
174
+ requiresArgument: true,
175
+ },
153
176
  {
154
177
  command: "/forkgroup",
155
178
  usage: "[title]",
@@ -404,6 +427,7 @@ export class ProxySessionController {
404
427
  private readonly pendingMergedForwards = new Map<string, PendingForwardAttachment>();
405
428
  private readonly pendingResourceForwards = new Map<string, PendingForwardAttachment>();
406
429
  private readonly relatedReactionMessageIds = new Map<string, string[]>();
430
+ private readonly shellCommandJobMonitors = new Map<string, ShellCommandJobMonitor>();
407
431
  private readonly unsubscribe: Array<() => void> = [];
408
432
  private readonly recoveryActivityHeartbeat: NodeJS.Timeout;
409
433
  private startupRecovery?: Promise<void>;
@@ -418,6 +442,9 @@ export class ProxySessionController {
418
442
  private readonly lifecycle?: ProxyLifecycle,
419
443
  private readonly shellCommandExecutor: ShellCommandExecutor = executeShellCommand,
420
444
  private readonly windowsDriveLister: WindowsDriveLister = listWindowsDriveRoots,
445
+ private readonly shellCommandJobs: ShellCommandJobManagerLike = new ShellCommandJobManager(
446
+ path.join(path.dirname(config.storage.sqlitePath), "command-jobs"),
447
+ ),
421
448
  ) {
422
449
  for (const [, runtime] of this.runtimes.entries()) {
423
450
  this.unsubscribe.push(
@@ -708,7 +735,9 @@ export class ProxySessionController {
708
735
  message.userId,
709
736
  message,
710
737
  );
711
- if (!isPromptCommand(command)) await this.finalizeStandaloneMessageReaction(message.messageId, "completed");
738
+ if (!commandDefersReactionFinalization(command)) {
739
+ await this.finalizeStandaloneMessageReaction(message.messageId, "completed");
740
+ }
712
741
  } catch (error) {
713
742
  await this.finalizeStandaloneMessageReaction(message.messageId, "failed");
714
743
  await this.sendError(message.contextKey, error);
@@ -731,7 +760,9 @@ export class ProxySessionController {
731
760
  message.userId,
732
761
  message,
733
762
  );
734
- if (!isPromptCommand(command)) await this.finalizeStandaloneMessageReaction(message.messageId, "completed");
763
+ if (!commandDefersReactionFinalization(command)) {
764
+ await this.finalizeStandaloneMessageReaction(message.messageId, "completed");
765
+ }
735
766
  } catch (error) {
736
767
  await this.finalizeStandaloneMessageReaction(message.messageId, "failed");
737
768
  await this.sendError(message.contextKey, error);
@@ -786,6 +817,11 @@ export class ProxySessionController {
786
817
  } else if (kind === "turn_cancel") {
787
818
  const sessionId = String(scopedAction.value.sessionId ?? "");
788
819
  await this.cancelSession(this.requireSession(contextKey, sessionId));
820
+ } else if (kind === "shell_command_cancel") {
821
+ await this.cancelShellCommandJob(
822
+ contextKey,
823
+ String(scopedAction.value.jobId ?? ""),
824
+ );
789
825
  } else if (kind === "turn_reset") {
790
826
  const sessionId = String(scopedAction.value.sessionId ?? "");
791
827
  await this.resetCurrentSessionToTurn(
@@ -1057,6 +1093,7 @@ export class ProxySessionController {
1057
1093
  this.relatedReactionMessageIds.clear();
1058
1094
  for (const timer of this.recoveryRetryTimers.values()) clearTimeout(timer);
1059
1095
  this.recoveryRetryTimers.clear();
1096
+ for (const [jobId] of this.shellCommandJobMonitors) this.stopShellCommandJobMonitor(jobId);
1060
1097
  clearInterval(this.recoveryActivityHeartbeat);
1061
1098
  }
1062
1099
 
@@ -1700,11 +1737,14 @@ export class ProxySessionController {
1700
1737
  }
1701
1738
  switch (command.type) {
1702
1739
  case "shell":
1703
- await this.runShellCommand(contextKey, command.command);
1740
+ await this.runShellCommand(contextKey, command.command, messageId);
1704
1741
  return;
1705
1742
  case "dir":
1706
1743
  await this.openDirectoryBrowser(contextKey, command.directory);
1707
1744
  return;
1745
+ case "file":
1746
+ await this.sendCurrentTaskFile(contextKey, command.filePath);
1747
+ return;
1708
1748
  case "new":
1709
1749
  if (command.projectless && this.ensureAgent(context.defaultAgent).kind !== "app-server") {
1710
1750
  throw new Error("/new --nodir 仅支持 App Server Agent。");
@@ -1872,6 +1912,7 @@ export class ProxySessionController {
1872
1912
  }
1873
1913
 
1874
1914
  private async runStartupRecovery(): Promise<void> {
1915
+ await this.recoverShellCommandJobs();
1875
1916
  this.backfillTurnAttemptsForUpgrade();
1876
1917
  const attempts = this.store.listIncompleteTurnAttempts();
1877
1918
  const recoveryCutoff = Date.now() - RECOVERY_ACTIVITY_WINDOW_MS;
@@ -3186,7 +3227,10 @@ export class ProxySessionController {
3186
3227
  executionSettings: SessionExecutionSettings,
3187
3228
  ): Promise<CreatedFeishuTaskGroup> {
3188
3229
  const explicitTitle = normalizeTaskTitle(requestedTitle);
3189
- const taskTitle = explicitTitle ?? `新任务 (${formatGroupDateSuffix(new Date())})`;
3230
+ const taskTitle = explicitTitle ?? `新任务 (${formatGroupNameDate(
3231
+ new Date(),
3232
+ this.config.feishu?.groupNameFormat?.dateFormat ?? DEFAULT_GROUP_NAME_FORMAT.dateFormat,
3233
+ )})`;
3190
3234
  const group = await this.createFeishuGroupContext(
3191
3235
  sourceContextKey,
3192
3236
  agentName,
@@ -3194,7 +3238,6 @@ export class ProxySessionController {
3194
3238
  userId,
3195
3239
  boundProjectCwd,
3196
3240
  "/newgroup",
3197
- false,
3198
3241
  );
3199
3242
 
3200
3243
  let task: SessionRecord;
@@ -3295,7 +3338,6 @@ export class ProxySessionController {
3295
3338
  userId,
3296
3339
  boundProjectCwd,
3297
3340
  "/forkgroup",
3298
- false,
3299
3341
  );
3300
3342
 
3301
3343
  let forked: ForkSessionResult;
@@ -3338,12 +3380,17 @@ export class ProxySessionController {
3338
3380
  userId: string | undefined,
3339
3381
  boundProjectCwd: string | undefined,
3340
3382
  commandName: "/newgroup" | "/forkgroup",
3341
- includeTimestamp: boolean,
3342
3383
  ): Promise<CreatedFeishuGroupContext> {
3343
3384
  if (!userId?.startsWith("ou_")) {
3344
3385
  throw new Error(`${commandName} 只能由具有 open_id 的飞书用户消息触发。`);
3345
3386
  }
3346
- const groupName = formatNewGroupName(agentName, boundProjectCwd, taskTitle, new Date(), includeTimestamp);
3387
+ const groupName = formatNewGroupName({
3388
+ agentName,
3389
+ projectCwd: boundProjectCwd,
3390
+ taskName: taskTitle,
3391
+ date: new Date(),
3392
+ format: this.config.feishu?.groupNameFormat,
3393
+ });
3347
3394
  const group = await this.outbound.createGroup(sourceContextKey, {
3348
3395
  name: groupName,
3349
3396
  userOpenId: userId,
@@ -4510,106 +4557,155 @@ export class ProxySessionController {
4510
4557
  }
4511
4558
  }
4512
4559
 
4513
- private async runShellCommand(contextKey: string, command: string): Promise<void> {
4560
+ private async runShellCommand(
4561
+ contextKey: string,
4562
+ command: string,
4563
+ sourceMessageId?: string,
4564
+ ): Promise<void> {
4514
4565
  const cwd = this.currentSession(contextKey)?.cwd ?? this.config.defaults.cwd;
4515
- const startedAt = Date.now();
4516
- let latestOutput: ShellCommandOutputSnapshot = {
4517
- stdout: "",
4518
- stderr: "",
4519
- outputTruncated: false,
4520
- };
4521
- let runningState: ShellCommandCardView = {
4566
+ const created = await this.shellCommandJobs.createJob({ contextKey, sourceMessageId, command, cwd });
4567
+ let cardMessageId: string | undefined;
4568
+ const initialState: ShellCommandCardView = {
4569
+ jobId: created.id,
4570
+ contextKey,
4522
4571
  command,
4523
4572
  cwd,
4524
- ...latestOutput,
4573
+ output: "",
4574
+ outputTruncated: false,
4525
4575
  status: "running",
4526
4576
  elapsedMs: 0,
4527
4577
  };
4528
- const cardMessageId = await this.outbound.sendInteractiveCard(
4529
- contextKey,
4530
- this.cardRenderer.renderShellCommandCard(runningState),
4531
- );
4532
- const scheduler = cardMessageId
4533
- ? new CardUpdateScheduler<ShellCommandCardView>({
4534
- render: (state) => this.cardRenderer.renderShellCommandCard(state),
4535
- write: (card) => this.outbound.updateInteractiveCard(contextKey, cardMessageId, card),
4536
- onError: (error) => this.logger.warn(
4537
- { error, contextKey, messageId: cardMessageId },
4538
- "Failed to update a shell command card.",
4539
- ),
4540
- })
4541
- : undefined;
4542
- scheduler?.seed(runningState);
4543
- const elapsedTimer = scheduler ? setInterval(() => {
4544
- runningState = { ...runningState, elapsedMs: Date.now() - startedAt };
4545
- scheduler.update(runningState);
4546
- }, SHELL_COMMAND_ELAPSED_UPDATE_INTERVAL_MS) : undefined;
4547
- elapsedTimer?.unref?.();
4548
-
4549
- const updateRunningOutput = (snapshot: ShellCommandOutputSnapshot): void => {
4550
- latestOutput = snapshot;
4551
- runningState = {
4552
- ...runningState,
4553
- ...snapshot,
4554
- elapsedMs: Date.now() - startedAt,
4555
- };
4556
- scheduler?.update(runningState);
4557
- };
4558
-
4559
- let result: ShellCommandResult;
4560
4578
  try {
4561
- result = await this.shellCommandExecutor(command, cwd, { onOutput: updateRunningOutput });
4579
+ cardMessageId = await this.outbound.sendInteractiveCard(
4580
+ contextKey,
4581
+ this.cardRenderer.renderShellCommandCard(initialState),
4582
+ );
4583
+ if (!cardMessageId) throw new Error("Failed to create the shell command card.");
4584
+ const bound = await this.shellCommandJobs.bindCard(created.id, cardMessageId);
4585
+ await this.shellCommandJobs.startJob(created.id);
4586
+ this.monitorShellCommandJob(bound, initialState);
4562
4587
  } catch (error) {
4563
- if (elapsedTimer) clearInterval(elapsedTimer);
4564
- const failedCard = this.cardRenderer.renderShellCommandCard({
4565
- command,
4566
- cwd,
4567
- ...latestOutput,
4568
- stderr: [latestOutput.stderr.trimEnd(), runtimeErrorMessage(error)].filter(Boolean).join("\n"),
4569
- status: "failed",
4570
- elapsedMs: Date.now() - startedAt,
4571
- });
4572
- if (scheduler) {
4588
+ await this.shellCommandJobs.failJob(created.id, runtimeErrorMessage(error)).catch(() => undefined);
4589
+ if (cardMessageId) {
4573
4590
  try {
4574
- await scheduler.flush({
4575
- ...runningState,
4576
- stderr: [latestOutput.stderr.trimEnd(), runtimeErrorMessage(error)].filter(Boolean).join("\n"),
4577
- status: "failed",
4578
- elapsedMs: Date.now() - startedAt,
4579
- });
4591
+ const failed = await this.shellCommandJobs.readJob(created.id);
4592
+ await this.outbound.updateInteractiveCard(
4593
+ contextKey,
4594
+ cardMessageId,
4595
+ this.cardRenderer.renderShellCommandCard(shellCommandJobCardView(failed)),
4596
+ );
4597
+ await this.shellCommandJobs.markPresented(created.id);
4580
4598
  } catch (cardError) {
4581
4599
  this.logger.warn(
4582
- { error: cardError, contextKey, messageId: cardMessageId },
4583
- "Failed to finalize a shell command card.",
4600
+ { error: cardError, jobId: created.id, contextKey, messageId: cardMessageId },
4601
+ "Failed to finalize a shell command card after the runner failed to start.",
4584
4602
  );
4585
- } finally {
4586
- scheduler.dispose();
4587
4603
  }
4588
4604
  } else {
4589
- await this.outbound.sendInteractiveCard(contextKey, failedCard);
4605
+ await this.shellCommandJobs.markPresented(created.id).catch(() => undefined);
4590
4606
  }
4591
4607
  throw error;
4592
4608
  }
4609
+ }
4593
4610
 
4594
- if (elapsedTimer) clearInterval(elapsedTimer);
4595
- const finalState: ShellCommandCardView = {
4596
- command,
4597
- cwd,
4598
- stdout: result.stdout,
4599
- stderr: result.stderr,
4600
- status: result.timedOut ? "timed_out" : result.exitCode === 0 ? "completed" : "failed",
4601
- exitCode: result.exitCode,
4602
- elapsedMs: Date.now() - startedAt,
4603
- outputTruncated: result.outputTruncated,
4611
+ private monitorShellCommandJob(
4612
+ job: ShellCommandJobSnapshot,
4613
+ seededState?: ShellCommandCardView,
4614
+ ): void {
4615
+ if (!job.cardMessageId || this.shellCommandJobMonitors.has(job.id)) return;
4616
+ const scheduler = new CardUpdateScheduler<ShellCommandCardView>({
4617
+ render: (state) => this.cardRenderer.renderShellCommandCard(state),
4618
+ write: (card) => this.outbound.updateInteractiveCard(job.contextKey, job.cardMessageId!, card),
4619
+ onError: (error) => this.logger.warn(
4620
+ { error, jobId: job.id, contextKey: job.contextKey, messageId: job.cardMessageId },
4621
+ "Failed to update a background shell command card.",
4622
+ ),
4623
+ });
4624
+ if (seededState) scheduler.seed(seededState);
4625
+ const monitor: ShellCommandJobMonitor = {
4626
+ scheduler,
4627
+ refreshing: false,
4628
+ timer: setInterval(() => {
4629
+ void this.refreshShellCommandJob(job.id);
4630
+ }, SHELL_COMMAND_JOB_POLL_INTERVAL_MS),
4604
4631
  };
4605
- if (scheduler) {
4606
- try {
4607
- await scheduler.flush(finalState);
4608
- } finally {
4609
- scheduler.dispose();
4632
+ monitor.timer.unref?.();
4633
+ this.shellCommandJobMonitors.set(job.id, monitor);
4634
+ void this.refreshShellCommandJob(job.id, seededState === undefined);
4635
+ }
4636
+
4637
+ private async refreshShellCommandJob(jobId: string, critical = false): Promise<void> {
4638
+ const monitor = this.shellCommandJobMonitors.get(jobId);
4639
+ if (!monitor || monitor.refreshing) return;
4640
+ monitor.refreshing = true;
4641
+ try {
4642
+ const job = await this.shellCommandJobs.readJob(jobId);
4643
+ const view = shellCommandJobCardView(job);
4644
+ if (isActiveShellCommandJob(job.status)) {
4645
+ monitor.scheduler.update(view, critical ? "critical" : "normal");
4646
+ return;
4610
4647
  }
4611
- } else {
4612
- await this.outbound.sendInteractiveCard(contextKey, this.cardRenderer.renderShellCommandCard(finalState));
4648
+ await monitor.scheduler.flush(view);
4649
+ if (job.sourceMessageId) {
4650
+ await this.finalizeStandaloneMessageReaction(
4651
+ job.sourceMessageId,
4652
+ job.status === "completed" ? "completed" : job.status === "cancelled" ? "cancelled" : "failed",
4653
+ );
4654
+ }
4655
+ await this.shellCommandJobs.markPresented(jobId);
4656
+ this.stopShellCommandJobMonitor(jobId);
4657
+ } catch (error) {
4658
+ this.logger.warn({ error, jobId }, "Failed to refresh a background shell command job.");
4659
+ } finally {
4660
+ const current = this.shellCommandJobMonitors.get(jobId);
4661
+ if (current) current.refreshing = false;
4662
+ }
4663
+ }
4664
+
4665
+ private stopShellCommandJobMonitor(jobId: string): void {
4666
+ const monitor = this.shellCommandJobMonitors.get(jobId);
4667
+ if (!monitor) return;
4668
+ clearInterval(monitor.timer);
4669
+ monitor.scheduler.dispose();
4670
+ this.shellCommandJobMonitors.delete(jobId);
4671
+ }
4672
+
4673
+ private async cancelShellCommandJob(contextKey: string, jobId: string): Promise<void> {
4674
+ if (!jobId) throw new Error("命令任务无效,请使用最新的命令卡片。");
4675
+ const job = await this.shellCommandJobs.readJob(jobId);
4676
+ if (job.contextKey !== contextKey) throw new Error("该命令不属于当前会话。");
4677
+ const requested = await this.shellCommandJobs.requestCancellation(jobId);
4678
+ if (!requested) {
4679
+ await this.outbound.sendText(contextKey, "该命令已经结束。");
4680
+ return;
4681
+ }
4682
+ if (!this.shellCommandJobMonitors.has(jobId)) this.monitorShellCommandJob(job);
4683
+ await this.refreshShellCommandJob(jobId, true);
4684
+ }
4685
+
4686
+ private async recoverShellCommandJobs(): Promise<void> {
4687
+ const jobs = await this.shellCommandJobs.listRecoverableJobs();
4688
+ if (jobs.length > 0) {
4689
+ this.logger.warn(
4690
+ { jobIds: jobs.map((job) => job.id) },
4691
+ "Recovering background shell command jobs after startup.",
4692
+ );
4693
+ }
4694
+ for (const job of jobs) {
4695
+ if (!job.cardMessageId || !this.outbound.canRoute(job.contextKey)) continue;
4696
+ if (
4697
+ job.status === "starting"
4698
+ && job.runnerPid === undefined
4699
+ && Date.now() - job.updatedAt >= SHELL_COMMAND_JOB_RELAUNCH_DELAY_MS
4700
+ ) {
4701
+ try {
4702
+ await this.shellCommandJobs.startJob(job.id);
4703
+ } catch (error) {
4704
+ await this.shellCommandJobs.failJob(job.id, runtimeErrorMessage(error)).catch(() => undefined);
4705
+ }
4706
+ }
4707
+ const refreshed = await this.shellCommandJobs.readJob(job.id);
4708
+ this.monitorShellCommandJob(refreshed);
4613
4709
  }
4614
4710
  }
4615
4711
 
@@ -4628,14 +4724,26 @@ export class ProxySessionController {
4628
4724
  contextKey: string,
4629
4725
  event: ChatUpdatedEvent,
4630
4726
  ): Promise<void> {
4631
- const parsed = parseAgentGroupName(event.afterName);
4632
- if (!parsed) return;
4633
4727
  const context = this.store.getUserContext(contextKey);
4634
4728
  if (!context?.currentSessionId) return;
4635
4729
  const record = this.store.getSessionForContext(context.currentSessionId, contextKey);
4636
4730
  if (!record || record.status === "closed") return;
4637
- if (record.agentName.toLowerCase() !== parsed.agentName.toLowerCase()) return;
4638
- const title = normalizeTaskTitle(parsed.title);
4731
+ const nameFormat = this.config.feishu?.groupNameFormat ?? DEFAULT_GROUP_NAME_FORMAT;
4732
+ const projectCwd = detectProjectlessWorkspace(record.cwd) ? undefined : record.cwd;
4733
+ const templateTitle = parseTaskNameFromGroupName({
4734
+ agentName: record.agentName,
4735
+ groupName: event.afterName,
4736
+ projectCwd,
4737
+ format: nameFormat,
4738
+ });
4739
+ const legacy = parseAgentGroupName(event.afterName);
4740
+ const legacyTitle = legacy && record.agentName.toLowerCase() === legacy.agentName.toLowerCase()
4741
+ ? legacy.title
4742
+ : undefined;
4743
+ if (isDefaultGroupNameFormat(nameFormat) && isLegacyGroupPrefixOnly(event.afterName)) return;
4744
+ const title = normalizeTaskTitle(isDefaultGroupNameFormat(nameFormat)
4745
+ ? legacyTitle ?? templateTitle
4746
+ : templateTitle ?? legacyTitle);
4639
4747
  if (!title || title === record.title) return;
4640
4748
 
4641
4749
  const loaded = await this.loadSession(record);
@@ -4990,6 +5098,13 @@ export class ProxySessionController {
4990
5098
  if (!stats.isFile()) throw new Error(`这不是普通文件:${filePath}`);
4991
5099
  }
4992
5100
 
5101
+ private async sendCurrentTaskFile(contextKey: string, requestedFilePath: string): Promise<void> {
5102
+ const record = this.requireCurrentSession(contextKey);
5103
+ const filePath = resolveUserPath(requestedFilePath, record.cwd);
5104
+ await this.assertSendableFile(filePath);
5105
+ await this.outbound.sendFile(contextKey, filePath);
5106
+ }
5107
+
4993
5108
  private async listSessions(
4994
5109
  contextKey: string,
4995
5110
  searchTerm?: string,
@@ -6505,14 +6620,29 @@ function isBotOwnedActiveTurn(record: SessionRecord, remote: RemoteSessionSummar
6505
6620
  }
6506
6621
 
6507
6622
  function isQueueIndependentCommand(command: Command): boolean {
6508
- if (["archive", "dismiss", "stop", "status", "restart", "mute", "help", "sessions", "dir", "goal", "nosteer"].includes(command.type)) return true;
6623
+ if (["archive", "dismiss", "stop", "status", "restart", "mute", "help", "sessions", "dir", "file", "goal", "nosteer", "shell"].includes(command.type)) return true;
6509
6624
  if (command.type === "agent") return command.agent === undefined;
6510
6625
  if (["model", "provider", "thinking", "permissions"].includes(command.type)) return true;
6511
6626
  return false;
6512
6627
  }
6513
6628
 
6629
+ function shellCommandJobCardView(job: ShellCommandJobSnapshot): ShellCommandCardView {
6630
+ const status = job.status === "starting" ? "running" : job.status;
6631
+ return {
6632
+ jobId: job.id,
6633
+ contextKey: job.contextKey,
6634
+ command: job.command,
6635
+ cwd: job.cwd,
6636
+ output: [job.output.trimEnd(), job.error].filter(Boolean).join("\n"),
6637
+ status,
6638
+ exitCode: job.exitCode,
6639
+ elapsedMs: Math.max(0, (job.completedAt ?? Date.now()) - job.startedAt),
6640
+ outputTruncated: job.outputTruncated,
6641
+ };
6642
+ }
6643
+
6514
6644
  function commandRequiresCurrentSession(command: Command): boolean {
6515
- if (["dismiss", "stop", "title", "turns", "model", "provider", "thinking", "permissions", "nosteer"].includes(command.type)) {
6645
+ if (["dismiss", "stop", "title", "turns", "file", "model", "provider", "thinking", "permissions", "nosteer"].includes(command.type)) {
6516
6646
  return true;
6517
6647
  }
6518
6648
  if (command.type === "archive" || command.type === "fork") return command.sessionId === undefined;
@@ -6696,8 +6826,8 @@ async function listWindowsDriveRoots(): Promise<WindowsDriveInfo[]> {
6696
6826
  return drives;
6697
6827
  }
6698
6828
 
6699
- function isPromptCommand(command: Command): boolean {
6700
- return command.type === "prompt" || command.type === "nosteer";
6829
+ function commandDefersReactionFinalization(command: Command): boolean {
6830
+ return command.type === "prompt" || command.type === "nosteer" || command.type === "shell";
6701
6831
  }
6702
6832
 
6703
6833
  function runtimePrompt(text: string, localImagePaths?: string[]): RuntimePrompt {
@@ -6871,44 +7001,6 @@ function isRecoveryAttemptRecent(attempt: TurnAttemptRecord, cutoff: number): bo
6871
7001
  return Number.isFinite(updatedAt) && updatedAt >= cutoff;
6872
7002
  }
6873
7003
 
6874
- function formatGroupDateSuffix(date: Date): string {
6875
- const month = String(date.getMonth() + 1).padStart(2, "0");
6876
- const day = String(date.getDate()).padStart(2, "0");
6877
- return `${month}-${day}`;
6878
- }
6879
-
6880
- function formatNewGroupName(
6881
- agentName: string,
6882
- projectCwd: string | undefined,
6883
- taskTitle: string,
6884
- date: Date,
6885
- includeTimestamp: boolean,
6886
- ): string {
6887
- const prefix = `[${agentName}] `;
6888
- const projectPrefix = projectCwd ? `${formatGroupProjectDirectory(projectCwd)} ` : "";
6889
- const titlePrefix = `${prefix}${projectPrefix}`;
6890
- const timestampSuffix = includeTimestamp ? ` (${formatGroupDateSuffix(date)})` : "";
6891
- const availableTitleLength = FEISHU_GROUP_NAME_MAX_LENGTH
6892
- - Array.from(titlePrefix).length
6893
- - Array.from(timestampSuffix).length;
6894
- const title = truncateTail(taskTitle, availableTitleLength);
6895
- return truncateTail(`${titlePrefix}${title}${timestampSuffix}`, FEISHU_GROUP_NAME_MAX_LENGTH);
6896
- }
6897
-
6898
- const FEISHU_GROUP_NAME_MAX_LENGTH = 60;
6899
- const GROUP_PROJECT_DIRECTORY_MAX_LENGTH = 15;
6900
-
6901
- function formatGroupProjectDirectory(projectCwd: string): string {
6902
- const value = abbreviateHomeDirectory(projectCwd);
6903
- const levels = value
6904
- .replace(/[\\/]+$/, "")
6905
- .split(/[\\/]+/)
6906
- .filter(Boolean)
6907
- .slice(-2);
6908
- const separator = value.includes("\\") ? path.win32.sep : path.posix.sep;
6909
- return `[${fitTrailingPathLevels(levels, GROUP_PROJECT_DIRECTORY_MAX_LENGTH, separator)}]`;
6910
- }
6911
-
6912
7004
  function abbreviateHomeDirectory(value: string): string {
6913
7005
  const homeDirectory = os.homedir();
6914
7006
  const valueIsWindows = isWindowsAbsolutePath(value);
@@ -6925,29 +7017,6 @@ function isWindowsAbsolutePath(value: string): boolean {
6925
7017
  return /^[a-z]:[\\/]|^\\\\/iu.test(value);
6926
7018
  }
6927
7019
 
6928
- function fitTrailingPathLevels(levels: string[], maxLength: number, separator: string): string {
6929
- if (levels.length === 0) return "";
6930
- if (levels.length === 1) return truncatePathLevel(levels[0]!, maxLength);
6931
- const parent = levels[0]!;
6932
- const leaf = levels[1]!;
6933
- const joined = `${parent}${separator}${leaf}`;
6934
- if (Array.from(joined).length <= maxLength) return joined;
6935
- if (Array.from(leaf).length <= maxLength) return leaf;
6936
- return truncatePathLevel(leaf, maxLength);
6937
- }
6938
-
6939
- function truncatePathLevel(value: string, maxLength: number): string {
6940
- return truncateTail(value, maxLength);
6941
- }
6942
-
6943
- function truncateTail(value: string, maxLength: number): string {
6944
- const characters = Array.from(value);
6945
- if (characters.length <= maxLength) return value;
6946
- if (maxLength <= 0) return "";
6947
- if (maxLength <= 3) return ".".repeat(maxLength);
6948
- return `${characters.slice(0, maxLength - 3).join("")}...`;
6949
- }
6950
-
6951
7020
  function parseAgentGroupName(value: string): { agentName: string; title: string } | undefined {
6952
7021
  const match = /^\[([^[\]]+)\]\s+(.+)$/.exec(value.trim());
6953
7022
  const agentName = match?.[1]?.trim();
@@ -6958,6 +7027,15 @@ function parseAgentGroupName(value: string): { agentName: string; title: string
6958
7027
  return agentName && title ? { agentName, title } : undefined;
6959
7028
  }
6960
7029
 
7030
+ function isDefaultGroupNameFormat(format: AppConfig["feishu"]["groupNameFormat"]): boolean {
7031
+ return format.project === DEFAULT_GROUP_NAME_FORMAT.project
7032
+ && format.projectless === DEFAULT_GROUP_NAME_FORMAT.projectless;
7033
+ }
7034
+
7035
+ function isLegacyGroupPrefixOnly(value: string): boolean {
7036
+ return /^\[[^[\]]+\]\s+\[[^[\]]+\]\s*$/u.test(value.trim());
7037
+ }
7038
+
6961
7039
  function validateGoalObjective(objective: string): void {
6962
7040
  const length = Array.from(objective.trim()).length;
6963
7041
  if (length === 0) throw new Error("Goal 不能为空。");