chatccc 0.2.267 → 0.2.268

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/README.md CHANGED
@@ -426,11 +426,17 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
426
426
  | `/abd<内容>` | 去掉 `/abd` 前缀后把内容发给 Agent,并在消息末尾追加第一性原理需求澄清提示 |
427
427
  | `/plan <内容>` | 只读计划模式:仅允许读文件和 stop-stuck-loop 请求,不执行任何写操作 |
428
428
  | `/ask <内容>` | 只读问答模式:与 /plan 相同,仅允许读文件和 stop-stuck-loop 请求 |
429
- | `/restart` | 重启机器人进程 |
430
- | `/update` | 更新 npm 全局包并重启(仅限 `npm install -g chatccc` 安装的全局进程;同一飞书事件跨重启去重) |
429
+ | `/restart` | 重启机器人进程 |
430
+ | `/restart safe` | 停止接受新任务,等待现有会话、缓存消息和依赖安装完成后重启 |
431
+ | `/update` | 更新 npm 全局包并重启(仅限 `npm install -g chatccc` 安装的全局进程;同一飞书事件跨重启去重) |
432
+ | `/update safe` | 停止接受新任务,排空现有工作后更新并重启 |
433
+ | `/safestatus` | 查看安全重启/更新的等待状态 |
434
+ | `/cancelsf` | 取消尚未开始执行的安全重启/更新预约 |
431
435
  | `/deleteg` | 解散当前飞书会话群;Agent 会话记录保留 |
432
436
 
433
- `/update` 会在执行 npm 更新前把飞书消息或按钮事件 ID 原子写入 `~/.chatccc/state/update-command-guard.json`。同一 ID 跨重启重投时会静默忽略;用户主动发送的新 `/update` 因事件 ID 不同,仍可立即执行。该保护仅作用于 `/update`,普通消息与 `/restart` 的处理不变。
437
+ `/update` `/update safe` 会把飞书消息或按钮事件 ID 原子写入 `~/.chatccc/state/update-command-guard.json`。同一 ID 跨重启重投时会静默忽略;用户主动发送的新更新指令因事件 ID 不同,仍可执行。该保护不改变普通消息与重启指令的处理方式。
438
+
439
+ `/restart safe` 与 `/update safe` 会先建立全局准入门禁:指令到达前已经运行或进入单会话缓存队列的工作会继续完成,之后到达的新普通任务会被提示在维护完成后重发。维护任务持久化到 `~/.chatccc/state/safe-maintenance.json`,进程意外退出后可继续排空;依赖安装、会话收尾、自动恢复和 Agent Teams 执行也计入等待条件。内存缓存随重启自然重建,磁盘会话、看板、图片等持久数据不会被清理。
434
440
 
435
441
  > **模型切换**:`/model` 查看当前会话 Agent 的可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。可选模型来自当前 Agent 的配置:Claude 使用 `claude.model` / `claude.subagentModel`;Cursor、Codex、CCC Agent 和 DSH 使用各自的 `model` / `alternativeModel`。
436
442
 
@@ -3,6 +3,7 @@ import { resolveDefaultAgentTool } from "./config.js";
3
3
  import { readUtf8JsonBody } from "./agent-rpc-body.js";
4
4
  import { delegateAgentTask } from "./agent-delegate-task.js";
5
5
  import { applySharedPrefix } from "./shared-prefix.js";
6
+ import { beginSafeMaintenanceTrackedWork, isSafeMaintenanceAdmissionClosed, } from "./safe-maintenance.js";
6
7
  export const AGENT_DELEGATE_TASK_PATH = "/api/agent/delegate-task";
7
8
  const MAX_REQUEST_BYTES = 128 * 1024;
8
9
  const VALID_TOOLS = new Set(["claude", "cursor", "codex"]);
@@ -83,21 +84,31 @@ export async function handleAgentDelegateTaskRequest(req, res, platform) {
83
84
  return true;
84
85
  }
85
86
  try {
86
- const result = await delegateAgentTask({
87
- platform,
88
- tool,
89
- cwd,
90
- promptText,
91
- openIds,
92
- chatNamePrefix: stringValue(payload.chat_name) || promptNamePrefix.slice(0, 10),
93
- });
94
- jsonReply(res, 200, {
95
- ok: true,
96
- chat_id: result.chatId,
97
- session_id: result.sessionId,
98
- tool: result.tool,
99
- cwd: result.cwd,
100
- });
87
+ if (isSafeMaintenanceAdmissionClosed()) {
88
+ jsonReply(res, 409, { ok: false, error: "ChatCCC 正在等待安全维护,暂不接受新的委派任务。" });
89
+ return true;
90
+ }
91
+ const release = beginSafeMaintenanceTrackedWork("agent-delegate-task");
92
+ try {
93
+ const result = await delegateAgentTask({
94
+ platform,
95
+ tool,
96
+ cwd,
97
+ promptText,
98
+ openIds,
99
+ chatNamePrefix: stringValue(payload.chat_name) || promptNamePrefix.slice(0, 10),
100
+ });
101
+ jsonReply(res, 200, {
102
+ ok: true,
103
+ chat_id: result.chatId,
104
+ session_id: result.sessionId,
105
+ tool: result.tool,
106
+ cwd: result.cwd,
107
+ });
108
+ }
109
+ finally {
110
+ release();
111
+ }
101
112
  }
102
113
  catch (err) {
103
114
  jsonReply(res, 500, { ok: false, error: err.message });
@@ -2,6 +2,7 @@ import { isAgentTool } from "../../agent-tool.js";
2
2
  import { sessionChatName } from "../../session-name.js";
3
3
  import { isValidFeishuOpenId, } from "../repositories/feishu-p2p-contact-store.js";
4
4
  import { BoardStoreError } from "../repositories/board-repository.js";
5
+ import { beginSafeMaintenanceTrackedWork, isSafeMaintenanceAdmissionClosed, } from "../../safe-maintenance.js";
5
6
  export class MainAgentService {
6
7
  options;
7
8
  now;
@@ -17,18 +18,26 @@ export class MainAgentService {
17
18
  return this.options.bindingRepository.get(projectId);
18
19
  }
19
20
  setPrimaryAgent(projectId, agentId, expectedRevision) {
21
+ if (isSafeMaintenanceAdmissionClosed()) {
22
+ throw new BoardStoreError("safe_maintenance_draining", "ChatCCC 正在等待安全维护,暂不接受新的主 Agent 设置。", 409);
23
+ }
20
24
  if (!isAgentTool(agentId)) {
21
25
  throw new BoardStoreError("invalid_request", `Unsupported primary Agent: ${String(agentId)}`, 400);
22
26
  }
27
+ const release = beginSafeMaintenanceTrackedWork("agent-team-main-agent");
23
28
  return this.exclusive(projectId, async () => {
24
29
  const board = await this.options.boardService.getBoard(projectId);
25
30
  if (board.revision !== expectedRevision) {
26
31
  throw new BoardStoreError("revision_conflict", `Board changed in another page (expected revision ${expectedRevision}, current ${board.revision})`, 409);
27
32
  }
28
33
  return this.provision(board, agentId, false);
29
- });
34
+ }).finally(release);
30
35
  }
31
36
  relinkWorkspace(projectId, workspacePath, expectedRevision) {
37
+ if (isSafeMaintenanceAdmissionClosed()) {
38
+ throw new BoardStoreError("safe_maintenance_draining", "ChatCCC 正在等待安全维护,暂不接受目录重新关联。", 409);
39
+ }
40
+ const release = beginSafeMaintenanceTrackedWork("agent-team-relink");
32
41
  return this.exclusive(projectId, async () => {
33
42
  const existing = await this.options.bindingRepository.get(projectId);
34
43
  const currentSessionId = existing ? (await this.resolveCurrentSession(existing)).sessionId : null;
@@ -39,7 +48,7 @@ export class MainAgentService {
39
48
  if (!existing || !board.primaryAgentId)
40
49
  return { board, binding: null };
41
50
  return this.provision(board, board.primaryAgentId, true);
42
- });
51
+ }).finally(release);
43
52
  }
44
53
  async provision(board, agentId, forceNewSession) {
45
54
  const existing = await this.options.bindingRepository.get(board.boardId);
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { isActiveTaskRun } from "../domain/task-run.js";
3
3
  import { BoardStoreError } from "../repositories/board-repository.js";
4
+ import { beginSafeMaintenanceTrackedWork, isSafeMaintenanceAdmissionClosed, } from "../../safe-maintenance.js";
4
5
  export class TaskExecutionService {
5
6
  options;
6
7
  now;
@@ -12,10 +13,25 @@ export class TaskExecutionService {
12
13
  this.now = options.now ?? (() => new Date());
13
14
  this.idFactory = options.idFactory ?? randomUUID;
14
15
  }
15
- listRuns(projectId) {
16
- return this.options.repository.listByProject(projectId);
16
+ async listRuns(projectId) {
17
+ const runs = await this.options.repository.listByProject(projectId);
18
+ return runs.map(withoutTranscript);
19
+ }
20
+ async getRun(projectId, runId) {
21
+ const run = await this.options.repository.get(runId);
22
+ if (!run || run.projectId !== projectId) {
23
+ throw new BoardStoreError("task_run_not_found", "找不到这次任务执行记录", 404);
24
+ }
25
+ if (!isActiveTaskRun(run) || !this.options.runtime.getTranscript)
26
+ return run;
27
+ const live = await this.options.runtime.getTranscript(run.sessionId).catch(() => []);
28
+ return live.length ? { ...run, transcript: mergeTranscript(run.transcript, live) } : run;
17
29
  }
18
30
  async startTask(projectId, taskId, expectedRevision) {
31
+ if (isSafeMaintenanceAdmissionClosed()) {
32
+ throw new BoardStoreError("safe_maintenance_draining", "ChatCCC 正在等待安全维护,暂不接受新的 Agent Team 任务。", 409);
33
+ }
34
+ const release = beginSafeMaintenanceTrackedWork("agent-team-task-start");
19
35
  return this.exclusive(projectId, async () => {
20
36
  let board = await this.options.boardService.getBoard(projectId);
21
37
  const active = (await this.options.repository.listByProject(projectId)).find(isActiveTaskRun);
@@ -54,6 +70,10 @@ export class TaskExecutionService {
54
70
  createdAt: timestamp,
55
71
  updatedAt: timestamp,
56
72
  };
73
+ run = {
74
+ ...run,
75
+ transcript: [{ type: "prompt", at: timestamp, text: taskPrompt(run) }],
76
+ };
57
77
  await this.options.repository.save(run);
58
78
  try {
59
79
  if (task.columnId !== "doing") {
@@ -80,7 +100,7 @@ export class TaskExecutionService {
80
100
  console.error(`[Agent Team] Task run ${run.runId} failed to persist its terminal state: ${err.message}`);
81
101
  });
82
102
  return { board, run };
83
- });
103
+ }).finally(release);
84
104
  }
85
105
  async stopRun(projectId, runId) {
86
106
  return this.exclusive(projectId, async () => {
@@ -125,14 +145,15 @@ export class TaskExecutionService {
125
145
  prompt: taskPrompt(run),
126
146
  });
127
147
  if (result.outcome === "done") {
128
- return this.finishRun(run, "succeeded", { result: result.result, moveTask: true });
148
+ return this.finishRun(run, "succeeded", { result: result.result, transcript: result.transcript, moveTask: true });
129
149
  }
130
150
  if (result.outcome === "stopped") {
131
- return this.finishRun(run, "canceled", { error: result.error, moveTask: true });
151
+ return this.finishRun(run, "canceled", { error: result.error, transcript: result.transcript, moveTask: true });
132
152
  }
133
153
  return this.finishRun(run, "failed", {
134
154
  error: result.error || (result.outcome === "auto_ended" ? "Agent response timed out" : "Agent execution failed"),
135
155
  result: result.result,
156
+ transcript: result.transcript,
136
157
  moveTask: true,
137
158
  });
138
159
  }
@@ -150,6 +171,7 @@ export class TaskExecutionService {
150
171
  state,
151
172
  updatedAt: now,
152
173
  finishedAt: now,
174
+ ...(details.transcript?.length ? { transcript: mergeTranscript(latest.transcript, details.transcript) } : {}),
153
175
  ...(details.result ? { result: details.result } : {}),
154
176
  ...(details.error ? { error: details.error } : {}),
155
177
  };
@@ -195,6 +217,13 @@ export class TaskExecutionService {
195
217
  }
196
218
  }
197
219
  }
220
+ function mergeTranscript(existing, appended) {
221
+ return [...(existing ?? []), ...appended];
222
+ }
223
+ function withoutTranscript(run) {
224
+ const { transcript: _transcript, ...summary } = run;
225
+ return summary;
226
+ }
198
227
  function taskPrompt(run) {
199
228
  return [
200
229
  "你正在执行 Agent Team 看板中的一个明确任务。请在当前工作目录完成任务,进行必要验证,并用简洁结果说明完成内容。",
@@ -1,4 +1,5 @@
1
1
  import { isAgentTool } from "../../agent-tool.js";
2
+ import { isExecutionTranscriptEntry } from "../../execution-transcript.js";
2
3
  export const TASK_RUN_SCHEMA_VERSION = 1;
3
4
  export function isActiveTaskRun(run) {
4
5
  return run.state === "queued" || run.state === "running";
@@ -25,5 +26,8 @@ export function parseTaskRun(value) {
25
26
  if (run[field] !== undefined && typeof run[field] !== "string")
26
27
  throw new Error(`Invalid task run ${field}`);
27
28
  }
29
+ if (run.transcript !== undefined && (!Array.isArray(run.transcript) || !run.transcript.every(isExecutionTranscriptEntry))) {
30
+ throw new Error("Invalid task run transcript");
31
+ }
28
32
  return run;
29
33
  }
@@ -109,6 +109,12 @@ export function createAgentTeamRequestHandler(options) {
109
109
  jsonReply(res, 200, { ok: true, runs });
110
110
  return true;
111
111
  }
112
+ const runDetailMatch = pathname.match(/^\/api\/agent-team\/boards\/([^/]+)\/runs\/([^/]+)$/);
113
+ if (runDetailMatch && method === "GET") {
114
+ const run = await requireTaskExecutionService(taskExecutionService()).getRun(decodeURIComponent(runDetailMatch[1]), decodeURIComponent(runDetailMatch[2]));
115
+ jsonReply(res, 200, { ok: true, run });
116
+ return true;
117
+ }
112
118
  const runTaskMatch = pathname.match(/^\/api\/agent-team\/boards\/([^/]+)\/tasks\/([^/]+)\/run$/);
113
119
  if (runTaskMatch && method === "POST") {
114
120
  const body = await bodyJson(req);
@@ -11,10 +11,14 @@ export function createTaskExecutionRuntime(platform) {
11
11
  }
12
12
  return {
13
13
  outcome,
14
+ ...(stream?.transcript?.length ? { transcript: stream.transcript } : {}),
14
15
  ...(stream?.finalReply ? { result: stream.finalReply } : {}),
15
16
  ...(stream?.terminalError?.message ? { error: stream.terminalError.message } : {}),
16
17
  };
17
18
  },
19
+ async getTranscript(sessionId) {
20
+ return (await readStreamState(sessionId))?.transcript ?? [];
21
+ },
18
22
  stop: stopSession,
19
23
  isSessionRunning,
20
24
  };
@@ -14,9 +14,9 @@ export const AGENT_TEAM_PAGE_HTML = `<!DOCTYPE html>
14
14
  .status-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 3px 10px;color:var(--muted);font-size:11px}.workspace-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.save-state{min-width:78px;text-align:right;color:var(--success);font-weight:700}.save-state::before{content:"● ";font-size:9px}.save-state.saving{color:#b7791f}.save-state.error{color:var(--danger)}
15
15
  .board-scroll{overflow-x:auto;padding-bottom:12px}.board{display:grid;grid-template-columns:repeat(5,minmax(230px,1fr));gap:11px;min-width:1200px}.column{display:flex;flex-direction:column;min-height:calc(100vh - 230px);background:var(--soft);border:1px solid var(--line);border-radius:15px;overflow:hidden}.column.drag-over{border-color:var(--accent);box-shadow:0 0 0 3px rgba(103,88,232,.13)}.column-head{display:flex;align-items:center;gap:8px;padding:11px 10px 7px 13px}.column-dot{width:7px;height:7px;border-radius:50%;background:var(--accent)}.column-title{font-size:13px;font-weight:800}.count{margin-left:auto;display:inline-grid;place-items:center;min-width:23px;height:23px;padding:0 7px;border-radius:999px;background:var(--panel);border:1px solid var(--line);color:var(--muted);font-size:10px}.column-add{width:27px;height:27px;display:grid;place-items:center;padding:0;border-color:transparent;border-radius:8px;background:transparent;color:var(--muted);font-size:19px;font-weight:500;line-height:1}.column-add:hover{border-color:var(--line);background:var(--panel);color:var(--accent)}.task-list{flex:1;min-height:100px;padding:4px 10px 10px}.task{position:relative;background:var(--panel2);border:1px solid var(--line);border-radius:11px;padding:12px;margin:8px 0;box-shadow:0 4px 15px rgba(34,42,65,.045);cursor:pointer}.task:hover{border-color:#d1d5e1;box-shadow:0 8px 22px rgba(34,42,65,.08)}.task.dragging{opacity:.35;cursor:grabbing}.task h3{font-size:13px;line-height:1.45;margin:0 42px 6px 0}.task p{font-size:11px;line-height:1.55;color:var(--muted);margin:0;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}.task-actions{display:flex;gap:4px;position:absolute;right:7px;top:7px;opacity:0;transition:opacity .15s}.task:hover .task-actions,.task:focus-within .task-actions{opacity:1}.icon-btn{padding:3px 6px;border-radius:6px;background:var(--soft);color:var(--muted);font-size:10px}.empty{padding:20px 8px;text-align:center;color:#9aa3b4;font-size:11px}
16
16
  .task.running{cursor:default;border-color:#cfc8ff}.task-run{margin-top:10px;padding-top:9px;border-top:1px solid var(--line)}.task-run-head{display:flex;align-items:center;justify-content:space-between;gap:7px}.run-status{font-size:10px;font-weight:800;color:var(--muted)}.run-status.running,.run-status.queued{color:var(--accent)}.run-status.succeeded{color:var(--success)}.run-status.failed,.run-status.interrupted{color:var(--danger)}.run-result{margin-top:6px!important;padding:7px 8px;border-radius:7px;background:var(--soft);display:-webkit-box!important;-webkit-line-clamp:3!important}.run-button{padding:5px 8px;font-size:10px;border-radius:7px}.run-button.primary{box-shadow:none}
17
- .modal-backdrop{position:fixed;inset:0;z-index:20;background:rgba(24,32,51,.36);display:grid;place-items:center;padding:20px;backdrop-filter:blur(3px)}.modal{width:min(560px,100%);background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:20px;box-shadow:0 28px 90px rgba(35,43,65,.24)}.modal h2{margin:0 0 16px}.field{display:grid;gap:7px;margin-bottom:14px}.field label{font-size:13px;color:var(--text);font-weight:650}.field textarea{min-height:150px;resize:vertical}.modal-actions{display:flex;justify-content:flex-end;gap:9px}.directory-modal{width:min(820px,100%)}.directory-layout{display:grid;grid-template-columns:180px minmax(0,1fr);min-height:430px;border:1px solid var(--line);border-radius:12px;overflow:hidden;margin-bottom:16px}.directory-sidebar{padding:12px;background:var(--soft);border-right:1px solid var(--line)}.directory-label{margin:2px 6px 9px;color:var(--muted);font-size:11px;font-weight:800;letter-spacing:.05em}.directory-locations{display:grid;gap:4px}.directory-location{width:100%;padding:8px 9px;border:0;background:transparent;text-align:left;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.directory-location:hover{border:0;background:var(--accent-soft);color:var(--accent)}.directory-main{min-width:0;display:flex;flex-direction:column;padding:12px}.directory-toolbar{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:7px}.directory-toolbar button{padding:8px 11px}.directory-toolbar input{min-width:0;padding:8px 10px}.directory-list{flex:1;height:310px;overflow:auto;margin-top:10px;border:1px solid var(--line);border-radius:10px;background:var(--soft);padding:6px}.directory-row{width:100%;display:flex;align-items:center;gap:9px;padding:9px 10px;border:0;background:transparent;text-align:left;font-size:12px}.directory-row:hover{border:0;background:#fff}.directory-row.selected{border:0;background:var(--accent-soft);color:var(--accent)}.directory-folder-icon{color:#b7791f;font-size:15px}.directory-empty{padding:46px 16px;text-align:center;color:var(--muted);font-size:12px}.directory-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:10px;color:var(--muted);font-size:11px}.directory-footer label{display:flex;align-items:center;gap:6px;white-space:nowrap}.directory-footer input{width:15px;height:15px;margin:0}.directory-selected{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right}.toast{position:fixed;right:22px;bottom:22px;z-index:30;max-width:min(420px,calc(100vw - 44px));padding:12px 15px;border-radius:10px;background:#253047;color:#fff;border:1px solid #35415a;box-shadow:0 16px 50px rgba(35,43,65,.25)}.toast.error{border-color:#ef9bad;background:#9f2942}
17
+ .modal-backdrop{position:fixed;inset:0;z-index:20;background:rgba(24,32,51,.36);display:grid;place-items:center;padding:20px;backdrop-filter:blur(3px)}.modal{width:min(560px,100%);background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:20px;box-shadow:0 28px 90px rgba(35,43,65,.24)}.modal h2{margin:0 0 16px}.field{display:grid;gap:7px;margin-bottom:14px}.field label{font-size:13px;color:var(--text);font-weight:650}.field textarea{min-height:150px;resize:vertical}.modal-actions{display:flex;justify-content:flex-end;gap:9px}.task-detail-modal{width:min(980px,100%);max-height:calc(100vh - 40px);padding:0;display:flex;flex-direction:column;overflow:hidden}.task-detail-scroll{min-height:0;overflow-y:auto;padding:22px 24px}.task-detail-modal>.modal-actions{flex:none;padding:14px 24px;border-top:1px solid var(--line);background:var(--panel)}.task-execution-details{margin-top:22px;padding-top:20px;border-top:1px solid var(--line)}.task-execution-title{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.task-execution-title h3{margin:0;font-size:16px}.run-meta{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-bottom:14px}.run-meta-item{padding:9px 10px;border:1px solid var(--line);border-radius:9px;background:var(--soft);min-width:0}.run-meta-item span{display:block;color:var(--muted);font-size:10px;margin-bottom:4px}.run-meta-item strong{display:block;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.execution-timeline{display:grid;gap:9px}.timeline-entry{border:1px solid var(--line);border-radius:10px;background:var(--soft);overflow:hidden}.timeline-entry.tool_use{border-left:3px solid var(--accent)}.timeline-entry.tool_result{border-left:3px solid var(--success)}.timeline-entry.error{border-left-color:var(--danger)}.timeline-head{display:flex;justify-content:space-between;gap:12px;padding:8px 10px;border-bottom:1px solid var(--line);color:var(--muted);font-size:10px;font-weight:800}.timeline-entry pre{margin:0;padding:11px 12px;max-height:none;overflow:visible;white-space:pre-wrap;overflow-wrap:anywhere;font:11px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--text)}.timeline-empty{padding:22px;border:1px dashed var(--line);border-radius:10px;text-align:center;color:var(--muted);font-size:12px}.directory-modal{width:min(820px,100%)}.directory-layout{display:grid;grid-template-columns:180px minmax(0,1fr);min-height:430px;border:1px solid var(--line);border-radius:12px;overflow:hidden;margin-bottom:16px}.directory-sidebar{padding:12px;background:var(--soft);border-right:1px solid var(--line)}.directory-label{margin:2px 6px 9px;color:var(--muted);font-size:11px;font-weight:800;letter-spacing:.05em}.directory-locations{display:grid;gap:4px}.directory-location{width:100%;padding:8px 9px;border:0;background:transparent;text-align:left;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.directory-location:hover{border:0;background:var(--accent-soft);color:var(--accent)}.directory-main{min-width:0;display:flex;flex-direction:column;padding:12px}.directory-toolbar{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:7px}.directory-toolbar button{padding:8px 11px}.directory-toolbar input{min-width:0;padding:8px 10px}.directory-list{flex:1;height:310px;overflow:auto;margin-top:10px;border:1px solid var(--line);border-radius:10px;background:var(--soft);padding:6px}.directory-row{width:100%;display:flex;align-items:center;gap:9px;padding:9px 10px;border:0;background:transparent;text-align:left;font-size:12px}.directory-row:hover{border:0;background:#fff}.directory-row.selected{border:0;background:var(--accent-soft);color:var(--accent)}.directory-folder-icon{color:#b7791f;font-size:15px}.directory-empty{padding:46px 16px;text-align:center;color:var(--muted);font-size:12px}.directory-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:10px;color:var(--muted);font-size:11px}.directory-footer label{display:flex;align-items:center;gap:6px;white-space:nowrap}.directory-footer input{width:15px;height:15px;margin:0}.directory-selected{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right}.toast{position:fixed;right:22px;bottom:22px;z-index:30;max-width:min(420px,calc(100vw - 44px));padding:12px 15px;border-radius:10px;background:#253047;color:#fff;border:1px solid #35415a;box-shadow:0 16px 50px rgba(35,43,65,.25)}.toast.error{border-color:#ef9bad;background:#9f2942}
18
18
  @media(max-width:1100px){.app-shell{grid-template-columns:78px minmax(0,1fr)}.sidebar{padding:22px 12px;align-items:center}.brand span:last-child,.nav-item span:last-child,.side-note{display:none}.nav-item{width:45px;height:45px;justify-content:center;padding:0}.workspace-bar{grid-template-columns:1fr 1fr}.workspace-bar input{grid-column:1/-1}.main-agent-bar{grid-template-columns:1fr}.main-agent-controls{justify-content:flex-end}.board{min-width:1200px}}
19
- @media(max-width:700px){.app-shell{display:block}.sidebar{display:none}.shell{padding:14px}.top{align-items:flex-start}.workspace-bar{grid-template-columns:1fr}.workspace-bar input{grid-column:auto}.main-agent-controls{display:grid;grid-template-columns:minmax(0,1fr) auto;justify-content:stretch}.main-agent-controls select{min-width:0}.board{min-width:1180px}.directory-layout{grid-template-columns:1fr}.directory-sidebar{border-right:0;border-bottom:1px solid var(--line)}.directory-locations{display:flex;overflow-x:auto}.directory-location{width:auto;min-width:max-content}.directory-toolbar{grid-template-columns:auto auto 1fr}.directory-list{height:270px}}
19
+ @media(max-width:700px){.app-shell{display:block}.sidebar{display:none}.shell{padding:14px}.top{align-items:flex-start}.workspace-bar{grid-template-columns:1fr}.workspace-bar input{grid-column:auto}.main-agent-controls{display:grid;grid-template-columns:minmax(0,1fr) auto;justify-content:stretch}.main-agent-controls select{min-width:0}.board{min-width:1180px}.task-detail-scroll{padding:18px}.task-detail-modal>.modal-actions{padding:12px 18px}.run-meta{grid-template-columns:repeat(2,minmax(0,1fr))}.directory-layout{grid-template-columns:1fr}.directory-sidebar{border-right:0;border-bottom:1px solid var(--line)}.directory-locations{display:flex;overflow-x:auto}.directory-location{width:auto;min-width:max-content}.directory-toolbar{grid-template-columns:auto auto 1fr}.directory-list{height:270px}}
20
20
  </style>
21
21
  </head>
22
22
  <body>
@@ -53,11 +53,18 @@ export const AGENT_TEAM_PAGE_HTML = `<!DOCTYPE html>
53
53
  </main>
54
54
  </div>
55
55
  <div id="modal" class="modal-backdrop hidden" role="dialog" aria-modal="true">
56
- <form class="modal" id="task-form">
57
- <h2 id="modal-title">添加任务</h2>
58
- <div class="field"><label for="task-title">标题</label><input id="task-title" maxlength="200" required></div>
59
- <div class="field"><label for="task-description">描述</label><textarea id="task-description" maxlength="20000"></textarea></div>
60
- <div class="modal-actions"><button type="button" id="cancel" class="ghost">取消</button><button type="submit" class="primary">保存</button></div>
56
+ <form class="modal task-detail-modal" id="task-form">
57
+ <div class="task-detail-scroll">
58
+ <h2 id="modal-title">添加任务</h2>
59
+ <div class="field"><label for="task-title">标题</label><input id="task-title" maxlength="200" required></div>
60
+ <div class="field"><label for="task-description">描述</label><textarea id="task-description" maxlength="20000"></textarea></div>
61
+ <section id="task-execution-details" class="task-execution-details hidden">
62
+ <div class="task-execution-title"><h3>完整执行过程</h3><span id="task-run-status" class="run-status"></span></div>
63
+ <div id="task-run-meta" class="run-meta"></div>
64
+ <div id="task-transcript" class="execution-timeline"></div>
65
+ </section>
66
+ </div>
67
+ <div class="modal-actions"><button type="button" id="cancel" class="ghost">取消</button><button type="submit" id="task-save" class="primary">保存</button></div>
61
68
  </form>
62
69
  </div>
63
70
  <div id="dm-modal" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="dm-modal-title">
@@ -100,7 +107,7 @@ export const AGENT_TEAM_PAGE_HTML = `<!DOCTYPE html>
100
107
  <div id="toast" class="toast hidden"></div>
101
108
  <script>
102
109
  (function(){
103
- var state={board:null,binding:null,runs:[],runPollTimer:null,runPollBusy:false,workspaces:[],agentOptions:[],dragTaskId:null,modalTaskId:null,modalColumnId:null,relinkBoardId:null,pendingAgentId:null,pendingWorkspacePath:null,workspaceSelectionVersion:0,directoryLocations:[],directoryPath:null,directoryParentPath:null,directorySelectedPath:null,directoryBrowseVersion:0,contactTimer:null,contactChecking:false,mutationQueue:Promise.resolve()};
110
+ var state={board:null,binding:null,runs:[],runPollTimer:null,runPollBusy:false,workspaces:[],agentOptions:[],dragTaskId:null,modalTaskId:null,modalColumnId:null,modalDetailVersion:0,relinkBoardId:null,pendingAgentId:null,pendingWorkspacePath:null,workspaceSelectionVersion:0,directoryLocations:[],directoryPath:null,directoryParentPath:null,directorySelectedPath:null,directoryBrowseVersion:0,contactTimer:null,contactChecking:false,mutationQueue:Promise.resolve()};
104
111
  var columns=['brainstorm','todo','doing','done','on_hold'];
105
112
  var recent=document.getElementById('recent');
106
113
  var pathInput=document.getElementById('workspace-path');
@@ -165,7 +172,7 @@ export const AGENT_TEAM_PAGE_HTML = `<!DOCTYPE html>
165
172
  function isActiveRun(run){return !!run&&(run.state==='queued'||run.state==='running');}
166
173
  function runLabel(run){var labels={queued:'等待执行',running:run.stopRequestedAt?'正在停止':'主 Agent 执行中',succeeded:'执行成功',failed:'执行失败',canceled:'已停止',interrupted:'重启时中断'};return labels[run.state]||run.state;}
167
174
  function scheduleRunPoll(){if(state.runPollTimer){clearTimeout(state.runPollTimer);state.runPollTimer=null;}if(!state.runs.some(isActiveRun))return;state.runPollTimer=setTimeout(pollRuns,1500);}
168
- async function pollRuns(){if(state.runPollBusy||!state.board)return scheduleRunPoll();state.runPollBusy=true;var boardId=state.board.boardId;try{var results=await Promise.all([api('/api/agent-team/boards/'+encodeURIComponent(boardId)),api('/api/agent-team/boards/'+encodeURIComponent(boardId)+'/runs')]);if(state.board&&state.board.boardId===boardId){state.board=results[0].board;state.binding=results[0].binding||null;state.runs=results[1].runs||[];renderBoard();if(!state.runs.some(isActiveRun))setSaving('已保存','');}}catch(error){toast(error.message,true);}finally{state.runPollBusy=false;scheduleRunPoll();}}
175
+ async function pollRuns(){if(state.runPollBusy||!state.board)return scheduleRunPoll();state.runPollBusy=true;var boardId=state.board.boardId;try{var results=await Promise.all([api('/api/agent-team/boards/'+encodeURIComponent(boardId)),api('/api/agent-team/boards/'+encodeURIComponent(boardId)+'/runs')]);if(state.board&&state.board.boardId===boardId){state.board=results[0].board;state.binding=results[0].binding||null;state.runs=results[1].runs||[];renderBoard();if(state.modalTaskId)await loadOpenTaskRunDetails(false);if(!state.runs.some(isActiveRun))setSaving('已保存','');}}catch(error){toast(error.message,true);}finally{state.runPollBusy=false;scheduleRunPoll();}}
169
176
  async function startTaskRun(task){if(!state.board)return;if(!state.binding||state.binding.status!=='ready')return toast('请先设置可用的主 Agent',true);try{setSaving('正在交给主 Agent…','saving');var result=await request('POST','/api/agent-team/boards/'+encodeURIComponent(state.board.boardId)+'/tasks/'+encodeURIComponent(task.id)+'/run',{expectedRevision:state.board.revision});state.board=result.board;state.runs=[result.run].concat(state.runs.filter(function(run){return run.runId!==result.run.runId;}));renderBoard();scheduleRunPoll();setSaving('任务执行中','saving');}catch(error){if(error.code==='revision_conflict')await reloadBoard();toast(error.message,true);setSaving('执行失败','error');}}
170
177
  async function stopTaskRun(run){if(!state.board)return;try{var result=await request('POST','/api/agent-team/boards/'+encodeURIComponent(state.board.boardId)+'/runs/'+encodeURIComponent(run.runId)+'/stop',{});state.runs=state.runs.map(function(item){return item.runId===result.run.runId?result.run:item;});renderBoard();scheduleRunPoll();toast('已请求主 Agent 停止任务');}catch(error){toast(error.message,true);}}
171
178
  function activeTasks(columnId){return state.board?state.board.tasks.filter(function(task){return !task.deletedAt&&task.columnId===columnId;}).sort(function(a,b){return a.order-b.order;}):[];}
@@ -176,9 +183,9 @@ export const AGENT_TEAM_PAGE_HTML = `<!DOCTYPE html>
176
183
  if(!tasks.length){var empty=document.createElement('div');empty.className='empty';empty.textContent='暂无任务';list.appendChild(empty);}
177
184
  tasks.forEach(function(task){var run=latestRun(task.id);var active=isActiveRun(run);var dragged=false;var card=document.createElement('article');card.className='task'+(active?' running':'');card.draggable=!active;card.dataset.taskId=task.id;
178
185
  var title=document.createElement('h3');title.textContent=task.title;card.appendChild(title);if(task.description){var desc=document.createElement('p');desc.textContent=task.description;card.appendChild(desc);}
179
- var actions=document.createElement('div');actions.className='task-actions';var edit=document.createElement('button');edit.type='button';edit.className='icon-btn';edit.textContent='编辑';edit.disabled=active;edit.addEventListener('click',function(event){event.stopPropagation();showEdit(task);});var del=document.createElement('button');del.type='button';del.className='icon-btn';del.textContent='删除';del.disabled=active;del.addEventListener('click',function(event){event.stopPropagation();removeTask(task);});actions.append(edit,del);card.appendChild(actions);
186
+ var actions=document.createElement('div');actions.className='task-actions';var edit=document.createElement('button');edit.type='button';edit.className='icon-btn';edit.textContent='编辑';edit.addEventListener('click',function(event){event.stopPropagation();showEdit(task);});var del=document.createElement('button');del.type='button';del.className='icon-btn';del.textContent='删除';del.disabled=active;del.addEventListener('click',function(event){event.stopPropagation();removeTask(task);});actions.append(edit,del);card.appendChild(actions);
180
187
  var runBox=document.createElement('div');runBox.className='task-run';var runHead=document.createElement('div');runHead.className='task-run-head';var status=document.createElement('span');status.className='run-status '+(run?run.state:'');status.textContent=run?runLabel(run)+(run.attempt>1?' · 第 '+run.attempt+' 次':''):'尚未执行';var runButton=document.createElement('button');runButton.type='button';runButton.className='run-button '+(!run||!active?'primary':'danger');runButton.textContent=active?'停止':(run?'重试':'交给主 Agent');runButton.addEventListener('click',function(event){event.stopPropagation();if(active)stopTaskRun(run);else startTaskRun(task);});runHead.append(status,runButton);runBox.appendChild(runHead);if(run&&(run.result||run.error)){var resultText=document.createElement('p');resultText.className='run-result';resultText.textContent=run.error||run.result;runBox.appendChild(resultText);}card.appendChild(runBox);
181
- card.addEventListener('click',function(){if(!active&&!dragged)showEdit(task);});card.addEventListener('dragstart',function(event){if(active){event.preventDefault();return;}dragged=true;state.dragTaskId=task.id;card.classList.add('dragging');event.dataTransfer.effectAllowed='move';});card.addEventListener('dragend',function(){state.dragTaskId=null;card.classList.remove('dragging');document.querySelectorAll('.column').forEach(function(el){el.classList.remove('drag-over');});setTimeout(function(){dragged=false;},0);});list.appendChild(card);
188
+ card.addEventListener('click',function(){if(!dragged)showEdit(task);});card.addEventListener('dragstart',function(event){if(active){event.preventDefault();return;}dragged=true;state.dragTaskId=task.id;card.classList.add('dragging');event.dataTransfer.effectAllowed='move';});card.addEventListener('dragend',function(){state.dragTaskId=null;card.classList.remove('dragging');document.querySelectorAll('.column').forEach(function(el){el.classList.remove('drag-over');});setTimeout(function(){dragged=false;},0);});list.appendChild(card);
182
189
  });
183
190
  });
184
191
  }
@@ -201,9 +208,16 @@ export const AGENT_TEAM_PAGE_HTML = `<!DOCTYPE html>
201
208
  if(!state.board||!agentId)return;state.pendingAgentId=agentId;
202
209
  state.mutationQueue=state.mutationQueue.then(async function(){setSaving('创建主 Agent…','saving');mainAgentButton.disabled=true;try{var result=await request('POST','/api/agent-team/boards/'+encodeURIComponent(state.board.boardId)+'/main-agent',{expectedRevision:state.board.revision,agentId:agentId});state.board=result.board;state.binding=result.binding;state.pendingAgentId=null;renderBoard();setSaving('已保存','');toast('主 Agent 群已就绪');return true;}catch(error){setSaving('设置失败','error');renderBoard();if(error.code==='feishu_dm_required')showDmModal();else if(error.code==='revision_conflict'){try{await reloadBoard();toast('其他页面修改了项目,已载入最新版本',true);}catch(reloadError){toast(reloadError.message,true);}}else toast(error.message,true);return false;}});return state.mutationQueue;
203
210
  }
204
- function showCreate(columnId){if(!state.board)return toast('请先打开一个工作目录',true);state.modalTaskId=null;state.modalColumnId=columnId;document.getElementById('modal-title').textContent='添加任务';document.getElementById('task-title').value='';document.getElementById('task-description').value='';document.getElementById('modal').classList.remove('hidden');setTimeout(function(){document.getElementById('task-title').focus();},0);}
205
- function showEdit(task){state.modalTaskId=task.id;state.modalColumnId=task.columnId;document.getElementById('modal-title').textContent='编辑任务';document.getElementById('task-title').value=task.title;document.getElementById('task-description').value=task.description;document.getElementById('modal').classList.remove('hidden');}
206
- function closeModal(){document.getElementById('modal').classList.add('hidden');state.modalTaskId=null;}
211
+ function formatRunTime(value){if(!value)return '';var date=new Date(value);return Number.isNaN(date.getTime())?String(value):date.toLocaleString();}
212
+ function addRunMeta(root,label,value){var item=document.createElement('div');item.className='run-meta-item';var key=document.createElement('span');key.textContent=label;var content=document.createElement('strong');content.textContent=value||'—';content.title=value||'—';item.append(key,content);root.appendChild(item);}
213
+ function transcriptLabel(entry){var labels={prompt:'任务指令',thinking:'思考',text:'主 Agent 回复',tool_use:'调用工具',tool_result:'工具结果',search:'联网搜索',compact:'上下文压缩',status:'状态变化',notice:'提示'};var label=labels[entry.type]||entry.type;if(entry.name)label+=' · '+entry.name;return label;}
214
+ function transcriptContent(entry){if(entry.type==='tool_use')return entry.input||'';if(entry.type==='tool_result')return entry.output||'';return entry.text||'';}
215
+ function addTranscriptEntry(root,entry){var row=document.createElement('article');row.className='timeline-entry '+entry.type+(entry.isError?' error':'');var head=document.createElement('div');head.className='timeline-head';var label=document.createElement('span');label.textContent=transcriptLabel(entry);var time=document.createElement('span');time.textContent=formatRunTime(entry.at);head.append(label,time);var body=document.createElement('pre');body.textContent=transcriptContent(entry)||'(无文本内容)';row.append(head,body);root.appendChild(row);}
216
+ function renderTaskExecution(task,run){var details=document.getElementById('task-execution-details');var title=document.getElementById('task-title');var description=document.getElementById('task-description');var save=document.getElementById('task-save');var cancel=document.getElementById('cancel');var active=isActiveRun(run);title.disabled=active;description.disabled=active;save.classList.toggle('hidden',active);cancel.textContent=active?'关闭':'取消';details.classList.remove('hidden');var status=document.getElementById('task-run-status');status.className='run-status '+(run?run.state:'');status.textContent=run?runLabel(run):'尚未执行';var meta=document.getElementById('task-run-meta');meta.innerHTML='';if(run){addRunMeta(meta,'执行次数','第 '+run.attempt+' 次');addRunMeta(meta,'执行 Agent',run.agentId);addRunMeta(meta,'开始时间',formatRunTime(run.startedAt||run.createdAt));addRunMeta(meta,'结束时间',formatRunTime(run.finishedAt));}else{addRunMeta(meta,'任务状态','尚未交给主 Agent');addRunMeta(meta,'创建时间',formatRunTime(task.createdAt));addRunMeta(meta,'更新时间',formatRunTime(task.updatedAt));}var transcript=document.getElementById('task-transcript');transcript.innerHTML='';var entries=run&&Array.isArray(run.transcript)?run.transcript.slice():[];var hasText=entries.some(function(entry){return entry.type==='text';});if(run&&run.result&&!hasText)entries.push({type:'text',at:run.finishedAt||run.updatedAt,text:run.result});if(run&&run.error)entries.push({type:'notice',at:run.finishedAt||run.updatedAt,text:run.error,isError:true});if(!entries.length){var empty=document.createElement('div');empty.className='timeline-empty';empty.textContent=run?'暂时还没有可显示的执行事件':'任务尚未执行,交给主 Agent 后会在这里记录完整过程。';transcript.appendChild(empty);return;}entries.forEach(function(entry){addTranscriptEntry(transcript,entry);});}
217
+ async function loadOpenTaskRunDetails(showError){if(!state.board||!state.modalTaskId)return;var task=state.board.tasks.find(function(candidate){return candidate.id===state.modalTaskId&&!candidate.deletedAt;});if(!task)return;var summary=latestRun(task.id);if(!summary){renderTaskExecution(task,null);return;}var version=++state.modalDetailVersion;try{var result=await api('/api/agent-team/boards/'+encodeURIComponent(state.board.boardId)+'/runs/'+encodeURIComponent(summary.runId));if(version!==state.modalDetailVersion||state.modalTaskId!==task.id)return;renderTaskExecution(task,result.run);}catch(error){if(showError!==false)toast(error.message,true);}}
218
+ function showCreate(columnId){if(!state.board)return toast('请先打开一个工作目录',true);state.modalTaskId=null;state.modalColumnId=columnId;document.getElementById('modal-title').textContent='添加任务';document.getElementById('task-title').value='';document.getElementById('task-description').value='';document.getElementById('task-title').disabled=false;document.getElementById('task-description').disabled=false;document.getElementById('task-save').classList.remove('hidden');document.getElementById('cancel').textContent='取消';document.getElementById('task-execution-details').classList.add('hidden');document.getElementById('modal').classList.remove('hidden');setTimeout(function(){document.getElementById('task-title').focus();},0);}
219
+ function showEdit(task){state.modalTaskId=task.id;state.modalColumnId=task.columnId;document.getElementById('modal-title').textContent='任务详情';document.getElementById('task-title').value=task.title;document.getElementById('task-description').value=task.description;renderTaskExecution(task,latestRun(task.id));document.getElementById('modal').classList.remove('hidden');loadOpenTaskRunDetails(true);}
220
+ function closeModal(){state.modalDetailVersion++;document.getElementById('modal').classList.add('hidden');state.modalTaskId=null;}
207
221
  function removeTask(task){if(!confirm('删除任务“'+task.title+'”?'))return;mutate(function(){return request('DELETE','/api/agent-team/boards/'+encodeURIComponent(state.board.boardId)+'/tasks/'+encodeURIComponent(task.id),{expectedRevision:state.board.revision});});}
208
222
 
209
223
  document.querySelectorAll('[data-add]').forEach(function(button){button.addEventListener('click',function(){showCreate(button.dataset.add);});});
package/dist/src/cards.js CHANGED
@@ -143,7 +143,10 @@ export function buildHelpCard(userText, opts = {}) {
143
143
  "发送 **/ask** 以问答模式提问(只读,不执行写操作)",
144
144
  "发送 **/usage** 查看当前 Agent 的用量或余额",
145
145
  "发送 **/restart** 重启 ChatCCC 进程",
146
+ "发送 **/restart safe** 等待现有任务完成后安全重启",
146
147
  "发送 **/update** 更新并重启(仅 npm 全局安装可用)",
148
+ "发送 **/update safe** 等待现有任务完成后安全更新",
149
+ "发送 **/safestatus** 查看安全维护状态,**/cancelsf** 取消等待中的预约",
147
150
  ABD_HELP_LINE,
148
151
  ].join("\n");
149
152
  return JSON.stringify({
@@ -4,6 +4,7 @@ import { existsSync } from "node:fs";
4
4
  import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { delimiter, dirname, join, relative, resolve } from "node:path";
7
+ import { isSafeMaintenanceAdmissionClosed } from "../safe-maintenance.js";
7
8
  const STEP_DEFINITIONS = [
8
9
  ["preflight", "检查运行环境"],
9
10
  ["prepare", "准备临时目录"],
@@ -100,6 +101,9 @@ export class EngineManager {
100
101
  listSpecs() {
101
102
  return [...this.specs.values()];
102
103
  }
104
+ getActiveInstallIds() {
105
+ return [...this.activeInstalls.keys()].sort();
106
+ }
103
107
  getSpec(engineId) {
104
108
  const spec = this.specs.get(engineId);
105
109
  if (!spec)
@@ -133,6 +137,9 @@ export class EngineManager {
133
137
  const running = this.activeInstalls.get(engineId);
134
138
  if (running)
135
139
  return this.readJob(this.getSpec(engineId)).then((job) => job ?? this.newJob(this.getSpec(engineId)));
140
+ if (isSafeMaintenanceAdmissionClosed()) {
141
+ throw new Error("ChatCCC 正在等待安全维护,暂不接受新的依赖安装任务。");
142
+ }
136
143
  const spec = this.getSpec(engineId);
137
144
  const job = this.newJob(spec);
138
145
  await this.persistJob(spec, job);
@@ -0,0 +1,110 @@
1
+ export function appendExecutionTranscriptBlock(block, state, at = new Date().toISOString()) {
2
+ switch (block.type) {
3
+ case "text_reset":
4
+ state.transcript = [];
5
+ return;
6
+ case "text":
7
+ appendText(state, block.text, at);
8
+ return;
9
+ case "text_final":
10
+ // Some adapters emit both deltas and an authoritative final snapshot. Keep the
11
+ // ordered deltas when present, otherwise retain the snapshot as the full reply.
12
+ if (!state.transcript.some((entry) => entry.type === "text"))
13
+ appendText(state, block.text, at);
14
+ return;
15
+ case "thinking":
16
+ appendEntry(state, { type: "thinking", at, text: block.thinking });
17
+ return;
18
+ case "tool_use":
19
+ appendEntry(state, {
20
+ type: "tool_use",
21
+ at,
22
+ name: block.name,
23
+ ...(block.id ? { toolUseId: block.id } : {}),
24
+ input: stringifyTranscriptValue(block.input),
25
+ });
26
+ return;
27
+ case "tool_result":
28
+ appendEntry(state, {
29
+ type: "tool_result",
30
+ at,
31
+ ...(findToolName(state.transcript, block.tool_use_id) ? { name: findToolName(state.transcript, block.tool_use_id) } : {}),
32
+ toolUseId: block.tool_use_id,
33
+ output: stringifyTranscriptValue(block.content),
34
+ ...(block.is_error !== undefined ? { isError: block.is_error } : {}),
35
+ });
36
+ return;
37
+ case "search_result":
38
+ appendEntry(state, { type: "search", at, text: block.query });
39
+ return;
40
+ case "compact_boundary":
41
+ appendEntry(state, {
42
+ type: "compact",
43
+ at,
44
+ text: `${block.trigger === "manual" ? "手动" : "自动"}压缩:${block.pre_tokens} → ${block.post_tokens ?? "?"} tokens`,
45
+ });
46
+ return;
47
+ case "agent_status": {
48
+ const text = block.status === "compacting" ? "正在压缩上下文" : "正在生成回复";
49
+ const previous = state.transcript.at(-1);
50
+ if (previous?.type !== "status" || previous.text !== text)
51
+ appendEntry(state, { type: "status", at, text });
52
+ return;
53
+ }
54
+ case "redacted_thinking":
55
+ appendEntry(state, { type: "notice", at, text: "部分思考内容已被安全过滤" });
56
+ return;
57
+ case "agent_progress":
58
+ // Heartbeats carry no content and can occur very frequently. Persisting them
59
+ // would add noise without helping users reconstruct what happened.
60
+ return;
61
+ }
62
+ }
63
+ export function isExecutionTranscriptEntry(value) {
64
+ if (!value || typeof value !== "object" || Array.isArray(value))
65
+ return false;
66
+ const entry = value;
67
+ const types = [
68
+ "prompt", "thinking", "text", "tool_use", "tool_result", "search", "compact", "status", "notice",
69
+ ];
70
+ if (!types.includes(entry.type) || typeof entry.at !== "string")
71
+ return false;
72
+ for (const field of ["text", "name", "toolUseId", "input", "output"]) {
73
+ if (entry[field] !== undefined && typeof entry[field] !== "string")
74
+ return false;
75
+ }
76
+ return entry.isError === undefined || typeof entry.isError === "boolean";
77
+ }
78
+ function findToolName(entries, toolUseId) {
79
+ for (let index = entries.length - 1; index >= 0; index--) {
80
+ const entry = entries[index];
81
+ if (entry?.type === "tool_use" && entry.toolUseId === toolUseId)
82
+ return entry.name;
83
+ }
84
+ return undefined;
85
+ }
86
+ function appendText(state, text, at) {
87
+ if (!text)
88
+ return;
89
+ const previous = state.transcript.at(-1);
90
+ if (previous?.type === "text") {
91
+ previous.text = (previous.text ?? "") + text;
92
+ return;
93
+ }
94
+ appendEntry(state, { type: "text", at, text });
95
+ }
96
+ function appendEntry(state, entry) {
97
+ state.transcript.push(entry);
98
+ }
99
+ function stringifyTranscriptValue(value) {
100
+ if (typeof value === "string")
101
+ return value;
102
+ if (value === undefined)
103
+ return "";
104
+ try {
105
+ return JSON.stringify(value, null, 2);
106
+ }
107
+ catch {
108
+ return String(value);
109
+ }
110
+ }
package/dist/src/index.js CHANGED
@@ -47,7 +47,7 @@ import { loadSessionRegistryForBinding, rebuildBindingsFromRegistry, resetState,
47
47
  import { startChromeDevtoolsGuard, stopChromeDevtoolsGuard } from "./chrome-devtools-guard.js";
48
48
  import { rebuildSessionChatsFromRegistry, setQueueConsumer, } from "./session-chat-binding.js";
49
49
  import { fixStaleStreamStates } from "./stream-state.js";
50
- import { handleCommand } from "./orchestrator.js";
50
+ import { configureSafeMaintenanceRuntime, handleCommand, recoverSafeMaintenanceAfterStartup, } from "./orchestrator.js";
51
51
  import { createWechatAdapter, startWechatPlatform } from "./wechat-platform.js";
52
52
  import { handleCodexResetCardAction } from "./codex-reset-actions.js";
53
53
  import { resolveFeishuCardActionChatType } from "./card-action-routing.js";
@@ -103,11 +103,12 @@ function createFeishuAdapter() {
103
103
  }
104
104
  const feishuPlatform = createFeishuAdapter();
105
105
  const wechatPlatform = createWechatAdapter();
106
+ configureSafeMaintenanceRuntime([feishuPlatform, wechatPlatform]);
106
107
  setSessionPlatform(feishuPlatform);
107
108
  configureAgentTeamMainAgent(feishuPlatform);
108
109
  // 注册队列消费回调:session 生成完成后自动处理缓存消息
109
110
  setQueueConsumer((platform, msg) => {
110
- handleCommand(platform, msg.text, msg.chatId, msg.openId, msg.msgTimestamp, msg.chatType, msg.traceId).catch(err => console.error(`[${ts()}] Queue consume failed: ${err.message}`));
111
+ handleCommand(platform, msg.text, msg.chatId, msg.openId, msg.msgTimestamp, msg.chatType, msg.traceId, undefined, true).catch(err => console.error(`[${ts()}] Queue consume failed: ${err.message}`));
111
112
  });
112
113
  function getInnerEvent(data) {
113
114
  return (data.event ?? data);
@@ -596,6 +597,7 @@ async function main() {
596
597
  appendStartupTrace(opened ? "web-ui: opening simulate browser" : "web-ui: simulate browser unavailable", { url });
597
598
  }
598
599
  installShutdownHandlers(simServer, serviceLifecycle);
600
+ await recoverSafeMaintenanceAfterStartup();
599
601
  return;
600
602
  }
601
603
  if (Number.isNaN(CHATCCC_PORT) || CHATCCC_PORT < 1 || CHATCCC_PORT > 65535) {
@@ -647,6 +649,7 @@ async function main() {
647
649
  });
648
650
  try {
649
651
  await startConfiguredPlatforms(httpServer, { failOnFeishuError: true });
652
+ await recoverSafeMaintenanceAfterStartup();
650
653
  return { ok: true };
651
654
  }
652
655
  catch (err) {
@@ -714,6 +717,7 @@ async function main() {
714
717
  });
715
718
  }
716
719
  await startConfiguredPlatforms(httpServer, { failOnFeishuError: false });
720
+ await recoverSafeMaintenanceAfterStartup();
717
721
  }
718
722
  /**
719
723
  * 生命周期健康检查发现 HTTP Server 已停止监听时,优先原地恢复同一个 Server。
@@ -17,7 +17,7 @@ import { CLAUDE_MODEL, GIT_TIMEOUT_MS, PROJECT_ROOT, anthropicConfigDisplay, con
17
17
  import { buildHelpCard, buildEffortCard, buildFastModeCard, buildModelCard, buildStatusCard, buildCdContent, buildCdCard, buildSessionsCard, buildQueuedCard, buildQueueFullCard, buildCodexUsageCard, } from "./cards.js";
18
18
  import { formatGitResult, gitResultHeaderTemplate, runGitCommand, } from "./git-command.js";
19
19
  import { clearSessionModelOverride, clearSessionEffortOverride, getSessionStatus, getAllSessionsStatus, initClaudeSession, lastMsgTimestamps, resumeAndPrompt, sessionInfoMap, setSessionModelOverride, setSessionEffortOverride, switchChatBinding, recordSessionRegistry, getAdapterForTool, getEffectiveModelForTool, getEffectiveEffortForTool, getEffectiveFastModeForTool, setSessionFastModeOverride, stopSession, loadSessionRegistryForBinding, removeSessionRegistryRecord, saveSessionTool, recordChatPlatform, } from "./session.js";
20
- import { bindChatToSession, unbindChatFromSession, isSessionRunning, displayCards, enqueueMessage, cancelQueuedMessage, } from "./session-chat-binding.js";
20
+ import { bindChatToSession, unbindChatFromSession, isSessionRunning, displayCards, enqueueMessage, cancelQueuedMessage, getSessionDrainSnapshot, } from "./session-chat-binding.js";
21
21
  import { getCodexUsageSummary, getTenantAccessToken, sendPostMessage } from "./feishu-platform.js";
22
22
  import { getCursorUsageSummary } from "./cursor-usage.js";
23
23
  import { getChatGptSubscriptionStatus } from "./chatgpt-subscription.js";
@@ -25,8 +25,10 @@ import { applySharedPrefix } from "./shared-prefix.js";
25
25
  import { sessionChatName } from "./session-name.js";
26
26
  import { reloadRuntimeConfig } from "./runtime-reload.js";
27
27
  import { acquireUpdateCommandGuard } from "./update-command-guard.js";
28
- import { createInternalRestartEnv } from "./startup-lifecycle.js";
28
+ import { createInternalRestartEnv, INTERNAL_RESTART_ENV_VAR } from "./startup-lifecycle.js";
29
29
  import { resolveChatCccRuntimeSpawnSpec } from "./runtime-entry.js";
30
+ import { engineManager } from "./engines/engine-specs.js";
31
+ import { beginSafeMaintenanceTrackedWork, isSafeMaintenanceAdmissionClosed, safeMaintenanceCoordinator, } from "./safe-maintenance.js";
30
32
  import { feishuP2pContactStore, isValidFeishuOpenId, } from "./agent-team/repositories/feishu-p2p-contact-store.js";
31
33
  // ---------------------------------------------------------------------------
32
34
  // 辅助函数
@@ -577,10 +579,11 @@ function updLog(msg) {
577
579
  catch { }
578
580
  }
579
581
  /** 同步更新 npm 全局包并 spawn 新进程重启。不依赖 systemd 或任何服务管理器。 */
580
- function syncUpdateAndRestart() {
582
+ function syncUpdateAndRestart(options = {}) {
581
583
  updLog(`sync update start, pid=${process.pid}`);
582
584
  appendStartupTrace("update: sync update start", { pid: process.pid });
583
585
  const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
586
+ let updateSucceeded = false;
584
587
  // 1. npm update
585
588
  updLog(`running: ${npmCmd} update -g chatccc`);
586
589
  appendStartupTrace("update: npm update begin", { npmCmd });
@@ -590,6 +593,7 @@ function syncUpdateAndRestart() {
590
593
  const elapsed = Date.now() - t0;
591
594
  updLog(`npm update OK (${elapsed}ms): ${out.slice(0, 500)}`);
592
595
  appendStartupTrace("update: npm update OK", { elapsedMs: elapsed, outputLen: out.length });
596
+ updateSucceeded = true;
593
597
  }
594
598
  catch (e) {
595
599
  const elapsed = Date.now() - t0;
@@ -605,6 +609,7 @@ function syncUpdateAndRestart() {
605
609
  const elapsed2 = Date.now() - t1;
606
610
  updLog(`npm install fallback OK (${elapsed2}ms): ${out2.slice(0, 500)}`);
607
611
  appendStartupTrace("update: npm install fallback OK", { elapsedMs: elapsed2, outputLen: out2.length });
612
+ updateSucceeded = true;
608
613
  }
609
614
  catch (e2) {
610
615
  const elapsed2 = Date.now() - t1;
@@ -613,6 +618,11 @@ function syncUpdateAndRestart() {
613
618
  appendStartupTrace("update: npm install fallback failed", { elapsedMs: elapsed2, message: err2.message });
614
619
  }
615
620
  }
621
+ if (!updateSucceeded && options.spawnOnUpdateFailure === false) {
622
+ updLog("safe update aborted: both npm update and fallback install failed");
623
+ appendStartupTrace("update: safe update aborted before restart", {});
624
+ return undefined;
625
+ }
616
626
  // 2. resolve bin path
617
627
  const npmPrefix = process.env.NPM_PREFIX || "";
618
628
  const binName = process.platform === "win32" ? "chatccc.cmd" : "chatccc";
@@ -771,15 +781,106 @@ export async function decideRestartParentExit(child, timeoutMs, pollMs = 500, tr
771
781
  trace("restart: child alive after window, parent exiting", { childPid: child.pid });
772
782
  return true;
773
783
  }
784
+ const safeMaintenancePlatforms = new Map();
785
+ export function configureSafeMaintenanceRuntime(platforms) {
786
+ safeMaintenancePlatforms.clear();
787
+ for (const platform of platforms) {
788
+ if (platform.kind)
789
+ safeMaintenancePlatforms.set(platform.kind, platform);
790
+ }
791
+ safeMaintenanceCoordinator.configure({
792
+ getSnapshot() {
793
+ const sessions = getSessionDrainSnapshot();
794
+ return {
795
+ ...sessions,
796
+ activeEngineIds: engineManager.getActiveInstallIds(),
797
+ activeWorkLabels: [],
798
+ };
799
+ },
800
+ execute(kind) {
801
+ return kind === "update" ? executeSafeUpdate() : executeSafeRestart();
802
+ },
803
+ async notify(requester, message) {
804
+ const platform = safeMaintenancePlatforms.get(requester.platform);
805
+ if (!platform)
806
+ throw new Error(`Unavailable platform: ${requester.platform}`);
807
+ await platform.sendText(requester.chatId, message);
808
+ },
809
+ });
810
+ }
811
+ export function recoverSafeMaintenanceAfterStartup() {
812
+ return safeMaintenanceCoordinator.recoverAfterStartup(process.env[INTERNAL_RESTART_ENV_VAR] === "1");
813
+ }
814
+ async function executeSafeRestart() {
815
+ fileLog.flush();
816
+ appendStartupTrace("safe-maintenance: restart spawn begin", { fromPid: process.pid });
817
+ const child = spawnRestartChild();
818
+ child.unref();
819
+ const shouldExit = await decideRestartParentExit(child, RESTART_CHILD_READY_MS);
820
+ if (!shouldExit)
821
+ return false;
822
+ appendStartupTrace("safe-maintenance: restart parent exit", { childPid: child.pid });
823
+ process.exit(0);
824
+ }
825
+ async function executeSafeUpdate() {
826
+ fileLog.flush();
827
+ appendStartupTrace("safe-maintenance: update begin", { fromPid: process.pid });
828
+ const child = syncUpdateAndRestart({ spawnOnUpdateFailure: false });
829
+ if (!child)
830
+ return false;
831
+ child.unref();
832
+ const shouldExit = await decideRestartParentExit(child, RESTART_CHILD_READY_MS);
833
+ if (!shouldExit)
834
+ return false;
835
+ appendStartupTrace("safe-maintenance: update parent exit", { childPid: child.pid });
836
+ process.exit(0);
837
+ }
838
+ function safeMaintenanceRequester(platform, chatId, openId) {
839
+ return { platform: platform.kind ?? "feishu", chatId, openId };
840
+ }
841
+ function safeMaintenancePhaseLabel(phase) {
842
+ const labels = {
843
+ draining: "等待现有任务结束",
844
+ executing: "正在执行维护",
845
+ completed: "已完成",
846
+ failed: "执行失败",
847
+ };
848
+ return labels[phase] ?? phase;
849
+ }
850
+ async function safeMaintenanceStatusText() {
851
+ const status = await safeMaintenanceCoordinator.status();
852
+ if (!status.job)
853
+ return "当前没有安全维护预约。";
854
+ const snapshot = status.snapshot;
855
+ return [
856
+ `安全维护:${status.job.kind === "update" ? "更新并重启" : "重启"}`,
857
+ `状态:${safeMaintenancePhaseLabel(status.job.phase)}`,
858
+ `执行中/收尾会话:${snapshot.activeSessionIds.length}`,
859
+ `已接受的缓存消息:${snapshot.queuedSessionIds.length}`,
860
+ `依赖安装任务:${snapshot.activeEngineIds.length}`,
861
+ `其他处理中入口:${Math.max(0, snapshot.activeWorkLabels.length - 1)}`,
862
+ ...(status.job.lastError ? [`错误:${status.job.lastError}`] : []),
863
+ ].join("\n");
864
+ }
774
865
  // ---------------------------------------------------------------------------
775
866
  // handleCommand — 平台无关的命令分发
776
867
  // ---------------------------------------------------------------------------
777
- export async function handleCommand(platform, text, chatId, openId, msgTimestamp, chatType = "group", traceId, commandId) {
868
+ export async function handleCommand(platform, text, chatId, openId, msgTimestamp, chatType = "group", traceId, commandId, acceptedBeforeSafeMaintenance = false) {
869
+ const release = beginSafeMaintenanceTrackedWork(acceptedBeforeSafeMaintenance ? "accepted-queued-message" : "chat-command");
870
+ try {
871
+ await handleCommandInternal(platform, text, chatId, openId, msgTimestamp, chatType, traceId, commandId, acceptedBeforeSafeMaintenance);
872
+ }
873
+ finally {
874
+ release();
875
+ }
876
+ }
877
+ async function handleCommandInternal(platform, text, chatId, openId, msgTimestamp, chatType = "group", traceId, commandId, acceptedBeforeSafeMaintenance = false) {
778
878
  const tid = traceId ?? makeTraceId();
779
879
  const sharedPrefix = applySharedPrefix(text);
780
880
  const promptText = sharedPrefix.text;
781
881
  text = sharedPrefix.body;
782
882
  const textLower = text.toLowerCase();
883
+ const normalizedCommandText = textLower.trim().replace(/\s+/g, " ");
783
884
  const isCommandText = !sharedPrefix.matched && textLower.startsWith("/");
784
885
  recordChatPlatform(chatId, platform);
785
886
  if (platform.kind === "feishu" && chatType === "p2p" && isValidFeishuOpenId(openId)) {
@@ -788,6 +889,77 @@ export async function handleCommand(platform, text, chatId, openId, msgTimestamp
788
889
  console.error(`[${ts()}] [AGENT-TEAM] Failed to remember Feishu private contact: ${err.message}`);
789
890
  });
790
891
  }
892
+ if (isCommandText && normalizedCommandText === "/safestatus") {
893
+ logTrace(tid, "BRANCH", { cmd: "/safestatus" });
894
+ await platform.sendText(chatId, await safeMaintenanceStatusText()).catch(() => { });
895
+ logTrace(tid, "DONE", { outcome: "safe_maintenance_status" });
896
+ return;
897
+ }
898
+ if (isCommandText && normalizedCommandText === "/cancelsf") {
899
+ logTrace(tid, "BRANCH", { cmd: "/cancelsf" });
900
+ const canceled = await safeMaintenanceCoordinator.cancel();
901
+ if (!canceled) {
902
+ await platform.sendText(chatId, "当前没有可取消的安全维护预约;已经开始执行的维护也不能取消。").catch(() => { });
903
+ }
904
+ logTrace(tid, "DONE", { outcome: canceled ? "safe_maintenance_canceled" : "safe_maintenance_cancel_unavailable" });
905
+ return;
906
+ }
907
+ if (isCommandText && normalizedCommandText === "/restart safe") {
908
+ logTrace(tid, "BRANCH", { cmd: "/restart safe" });
909
+ try {
910
+ const job = await safeMaintenanceCoordinator.schedule("restart", safeMaintenanceRequester(platform, chatId, openId));
911
+ await platform.sendText(chatId, job.phase === "executing"
912
+ ? "安全维护已经开始执行,无法重复预约。"
913
+ : job.kind === "update"
914
+ ? "已经存在优先级更高的安全更新预约,将继续等待现有任务完成。发送 /safestatus 查看状态,/cancelsf 取消预约。"
915
+ : "已预约安全重启。现有会话、缓存消息和依赖安装会先自然完成;现在起不再接受新的普通任务。发送 /safestatus 查看状态,/cancelsf 取消预约。").catch(() => { });
916
+ logTrace(tid, "DONE", { outcome: "safe_restart_scheduled", jobId: job.jobId });
917
+ }
918
+ catch (error) {
919
+ await platform.sendText(chatId, `安全重启预约失败:${error.message}`).catch(() => { });
920
+ logTrace(tid, "DONE", { outcome: "safe_restart_schedule_failed", error: error.message });
921
+ }
922
+ return;
923
+ }
924
+ if (isCommandText && normalizedCommandText === "/update safe") {
925
+ logTrace(tid, "BRANCH", { cmd: "/update safe" });
926
+ const isGlobal = isRunningFromGlobalNpm();
927
+ if (!isGlobal) {
928
+ await platform.sendText(chatId, "当前进程非 npm 全局安装,无法使用 /update safe。请通过 npm install -g chatccc 安装后使用。").catch(() => { });
929
+ logTrace(tid, "DONE", { outcome: "safe_update_not_global" });
930
+ return;
931
+ }
932
+ const updateGuard = acquireUpdateCommandGuard({ commandId });
933
+ if (!updateGuard.allowed) {
934
+ if (updateGuard.reason !== "duplicate_id") {
935
+ await platform.sendText(chatId, "无法写入更新保护状态。为避免连续更新和重启,本次 /update safe 未预约。").catch(() => { });
936
+ }
937
+ logTrace(tid, "DONE", { outcome: updateGuard.reason === "duplicate_id" ? "safe_update_duplicate_id" : "safe_update_guard_failed" });
938
+ return;
939
+ }
940
+ try {
941
+ const job = await safeMaintenanceCoordinator.schedule("update", safeMaintenanceRequester(platform, chatId, openId));
942
+ await platform.sendText(chatId, job.phase === "executing"
943
+ ? "安全维护已经开始执行,无法重复预约。"
944
+ : "已预约安全更新。现有会话、缓存消息和依赖安装会先自然完成;现在起不再接受新的普通任务。发送 /safestatus 查看状态,/cancelsf 取消预约。").catch(() => { });
945
+ logTrace(tid, "DONE", { outcome: "safe_update_scheduled", jobId: job.jobId });
946
+ }
947
+ catch (error) {
948
+ await platform.sendText(chatId, `安全更新预约失败:${error.message}`).catch(() => { });
949
+ logTrace(tid, "DONE", { outcome: "safe_update_schedule_failed", error: error.message });
950
+ }
951
+ return;
952
+ }
953
+ const maintenanceAllowedCommands = new Set([
954
+ "/stop", "/cancel", "/state", "/sessions", "/usage", "/safestatus", "/cancelsf", "/restart", "/update", "/reload", "/help",
955
+ ]);
956
+ if (isSafeMaintenanceAdmissionClosed()
957
+ && !acceptedBeforeSafeMaintenance
958
+ && (!isCommandText || !maintenanceAllowedCommands.has(normalizedCommandText))) {
959
+ await platform.sendText(chatId, "ChatCCC 正在等待安全重启或更新,当前不接受新的任务。请在维护完成后重新发送;可用 /safestatus 查看状态。").catch(() => { });
960
+ logTrace(tid, "DONE", { outcome: "safe_maintenance_admission_closed" });
961
+ return;
962
+ }
791
963
  if (isCommandText && textLower === "/reload") {
792
964
  logTrace(tid, "BRANCH", { cmd: "/reload" });
793
965
  try {
@@ -808,6 +980,14 @@ export async function handleCommand(platform, text, chatId, openId, msgTimestamp
808
980
  }
809
981
  if (isCommandText && textLower === "/restart") {
810
982
  logTrace(tid, "BRANCH", { cmd: "/restart" });
983
+ const safeStatus = await safeMaintenanceCoordinator.status();
984
+ if (safeStatus.job?.phase === "executing") {
985
+ await platform.sendText(chatId, "安全维护已经开始执行,请勿重复重启。").catch(() => { });
986
+ logTrace(tid, "DONE", { outcome: "restart_blocked_by_executing_safe_maintenance" });
987
+ return;
988
+ }
989
+ if (safeStatus.job?.phase === "draining")
990
+ await safeMaintenanceCoordinator.cancel(false);
811
991
  await platform.sendText(chatId, "重启中...请几秒后发消息唤醒我").catch(() => { });
812
992
  logTrace(tid, "DONE", { outcome: "restart" });
813
993
  appendStartupTrace("restart: spawn begin", { fromPid: process.pid });
@@ -825,6 +1005,12 @@ export async function handleCommand(platform, text, chatId, openId, msgTimestamp
825
1005
  }
826
1006
  if (isCommandText && textLower === "/update") {
827
1007
  logTrace(tid, "BRANCH", { cmd: "/update" });
1008
+ const safeStatus = await safeMaintenanceCoordinator.status();
1009
+ if (safeStatus.job?.phase === "executing") {
1010
+ await platform.sendText(chatId, "安全维护已经开始执行,请勿重复更新。").catch(() => { });
1011
+ logTrace(tid, "DONE", { outcome: "update_blocked_by_executing_safe_maintenance" });
1012
+ return;
1013
+ }
828
1014
  const isGlobal = isRunningFromGlobalNpm();
829
1015
  appendStartupTrace("update: command received", { isGlobal, chatId });
830
1016
  if (!isGlobal) {
@@ -851,6 +1037,8 @@ export async function handleCommand(platform, text, chatId, openId, msgTimestamp
851
1037
  logTrace(tid, "DONE", { outcome: "update_guard_write_failed" });
852
1038
  return;
853
1039
  }
1040
+ if (safeStatus.job?.phase === "draining")
1041
+ await safeMaintenanceCoordinator.cancel(false);
854
1042
  await platform.sendText(chatId, "正在更新并重启,请稍候...").catch(() => { });
855
1043
  logTrace(tid, "DONE", { outcome: "update" });
856
1044
  appendStartupTrace("update: sync update begin", { fromPid: process.pid });
@@ -0,0 +1,271 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { USER_DATA_DIR } from "./config.js";
5
+ export const SAFE_MAINTENANCE_FILE = join(USER_DATA_DIR, "state", "safe-maintenance.json");
6
+ export const SAFE_MAINTENANCE_STABLE_IDLE_MS = 1_000;
7
+ const EMPTY_SNAPSHOT = {
8
+ activeSessionIds: [],
9
+ queuedSessionIds: [],
10
+ activeEngineIds: [],
11
+ activeWorkLabels: [],
12
+ };
13
+ export class SafeMaintenanceCoordinator {
14
+ filePath;
15
+ now;
16
+ idFactory;
17
+ stableIdleMs;
18
+ pollMs;
19
+ autoPoll;
20
+ trackedWork = new Map();
21
+ runtime = null;
22
+ job;
23
+ snapshot = EMPTY_SNAPSHOT;
24
+ stableSince = null;
25
+ timer = null;
26
+ tickRunning = false;
27
+ constructor(options = {}) {
28
+ this.filePath = options.filePath ?? SAFE_MAINTENANCE_FILE;
29
+ this.now = options.now ?? (() => new Date());
30
+ this.idFactory = options.idFactory ?? randomUUID;
31
+ this.stableIdleMs = options.stableIdleMs ?? SAFE_MAINTENANCE_STABLE_IDLE_MS;
32
+ this.pollMs = options.pollMs ?? 500;
33
+ this.autoPoll = options.autoPoll ?? true;
34
+ this.job = readJob(this.filePath);
35
+ }
36
+ configure(runtime) {
37
+ this.runtime = runtime;
38
+ }
39
+ isAdmissionClosed() {
40
+ return this.job?.phase === "draining" || this.job?.phase === "executing";
41
+ }
42
+ beginTrackedWork(label) {
43
+ const id = this.idFactory();
44
+ this.trackedWork.set(id, label);
45
+ let released = false;
46
+ return () => {
47
+ if (released)
48
+ return;
49
+ released = true;
50
+ this.trackedWork.delete(id);
51
+ };
52
+ }
53
+ async schedule(kind, requester) {
54
+ const now = this.now().toISOString();
55
+ if (this.job?.phase === "executing")
56
+ return structuredClone(this.job);
57
+ const previous = this.job;
58
+ if (this.job?.phase === "draining") {
59
+ const requesters = addRequester(this.job.requesters, requester);
60
+ this.job = {
61
+ ...this.job,
62
+ kind: this.job.kind === "update" || kind === "update" ? "update" : "restart",
63
+ requesters,
64
+ updatedAt: now,
65
+ };
66
+ }
67
+ else {
68
+ this.job = {
69
+ schemaVersion: 1,
70
+ jobId: this.idFactory(),
71
+ kind,
72
+ phase: "draining",
73
+ requestedAt: now,
74
+ updatedAt: now,
75
+ requesters: [requester],
76
+ };
77
+ }
78
+ this.stableSince = null;
79
+ try {
80
+ writeJob(this.filePath, this.job);
81
+ }
82
+ catch (error) {
83
+ this.job = previous;
84
+ throw error;
85
+ }
86
+ this.startPolling();
87
+ return structuredClone(this.job);
88
+ }
89
+ async cancel(notify = true) {
90
+ if (this.job?.phase !== "draining")
91
+ return false;
92
+ const requesters = this.job.requesters;
93
+ removeJob(this.filePath);
94
+ this.job = null;
95
+ this.snapshot = EMPTY_SNAPSHOT;
96
+ this.stableSince = null;
97
+ this.stopPolling();
98
+ if (notify)
99
+ await this.notifyAll(requesters, "已取消安全维护预约,ChatCCC 恢复接受新任务。");
100
+ return true;
101
+ }
102
+ async status() {
103
+ if (this.runtime && this.isAdmissionClosed())
104
+ this.snapshot = await this.collectSnapshot();
105
+ return {
106
+ job: this.job ? structuredClone(this.job) : null,
107
+ snapshot: structuredClone(this.snapshot),
108
+ waitingCount: snapshotCount(this.snapshot),
109
+ };
110
+ }
111
+ async tick() {
112
+ if (this.tickRunning || this.job?.phase !== "draining" || !this.runtime)
113
+ return;
114
+ this.tickRunning = true;
115
+ try {
116
+ this.snapshot = await this.collectSnapshot();
117
+ if (snapshotCount(this.snapshot) > 0) {
118
+ this.stableSince = null;
119
+ return;
120
+ }
121
+ const nowMs = this.now().getTime();
122
+ if (this.stableSince === null) {
123
+ this.stableSince = nowMs;
124
+ return;
125
+ }
126
+ if (nowMs - this.stableSince < this.stableIdleMs)
127
+ return;
128
+ await this.executeCurrentJob();
129
+ }
130
+ finally {
131
+ this.tickRunning = false;
132
+ }
133
+ }
134
+ async recoverAfterStartup(internalRestart) {
135
+ if (!this.job || !this.runtime)
136
+ return;
137
+ if (this.job.phase === "draining") {
138
+ this.startPolling();
139
+ await this.notifyAll(this.job.requesters, "ChatCCC 已恢复未完成的安全维护预约,继续等待现有任务结束。");
140
+ return;
141
+ }
142
+ if (this.job.phase !== "executing")
143
+ return;
144
+ if (internalRestart) {
145
+ const completed = { ...this.job, phase: "completed", updatedAt: this.now().toISOString() };
146
+ writeJob(this.filePath, completed);
147
+ this.job = completed;
148
+ await this.notifyAll(this.job.requesters, this.job.kind === "update" ? "ChatCCC 已安全更新并重新启动。" : "ChatCCC 已安全重新启动。");
149
+ return;
150
+ }
151
+ const message = "安全维护执行期间进程意外退出;为避免重启循环,未自动重试。";
152
+ const failed = {
153
+ ...this.job,
154
+ phase: "failed",
155
+ updatedAt: this.now().toISOString(),
156
+ lastError: message,
157
+ };
158
+ writeJob(this.filePath, failed);
159
+ this.job = failed;
160
+ await this.notifyAll(this.job.requesters, message);
161
+ }
162
+ async collectSnapshot() {
163
+ const external = await this.runtime.getSnapshot();
164
+ return {
165
+ activeSessionIds: [...external.activeSessionIds],
166
+ queuedSessionIds: [...external.queuedSessionIds],
167
+ activeEngineIds: [...external.activeEngineIds],
168
+ activeWorkLabels: [...external.activeWorkLabels, ...this.trackedWork.values()],
169
+ };
170
+ }
171
+ async executeCurrentJob() {
172
+ if (!this.job || !this.runtime)
173
+ return;
174
+ const executing = { ...this.job, phase: "executing", updatedAt: this.now().toISOString() };
175
+ writeJob(this.filePath, executing);
176
+ this.job = executing;
177
+ this.stopPolling();
178
+ const label = this.job.kind === "update" ? "更新并重启" : "重启";
179
+ await this.notifyAll(this.job.requesters, `现有任务已全部完成,开始安全${label}。`);
180
+ const started = await this.runtime.execute(this.job.kind).catch(async (error) => {
181
+ await this.markFailed(error instanceof Error ? error.message : String(error));
182
+ return false;
183
+ });
184
+ if (!started && this.job?.phase === "executing") {
185
+ await this.markFailed(`安全${label}未能启动,当前进程将继续提供服务。`);
186
+ }
187
+ }
188
+ async markFailed(message) {
189
+ if (!this.job)
190
+ return;
191
+ const failed = {
192
+ ...this.job,
193
+ phase: "failed",
194
+ updatedAt: this.now().toISOString(),
195
+ lastError: message,
196
+ };
197
+ writeJob(this.filePath, failed);
198
+ this.job = failed;
199
+ await this.notifyAll(this.job.requesters, message);
200
+ }
201
+ startPolling() {
202
+ if (!this.autoPoll || this.timer || this.job?.phase !== "draining")
203
+ return;
204
+ this.timer = setInterval(() => { void this.tick(); }, this.pollMs);
205
+ this.timer.unref?.();
206
+ }
207
+ stopPolling() {
208
+ if (!this.timer)
209
+ return;
210
+ clearInterval(this.timer);
211
+ this.timer = null;
212
+ }
213
+ async notifyAll(requesters, message) {
214
+ if (!this.runtime)
215
+ return;
216
+ await Promise.all(requesters.map((requester) => this.runtime.notify(requester, message).catch(() => { })));
217
+ }
218
+ }
219
+ function snapshotCount(snapshot) {
220
+ return snapshot.activeSessionIds.length
221
+ + snapshot.queuedSessionIds.length
222
+ + snapshot.activeEngineIds.length
223
+ + snapshot.activeWorkLabels.length;
224
+ }
225
+ function addRequester(requesters, requester) {
226
+ if (requesters.some((item) => item.platform === requester.platform && item.chatId === requester.chatId))
227
+ return requesters;
228
+ return [...requesters, requester];
229
+ }
230
+ function readJob(filePath) {
231
+ if (!existsSync(filePath))
232
+ return null;
233
+ try {
234
+ const value = JSON.parse(readFileSync(filePath, "utf8"));
235
+ if (value.schemaVersion !== 1 || !value.jobId || !Array.isArray(value.requesters))
236
+ return null;
237
+ if (!["restart", "update"].includes(value.kind) || !["draining", "executing", "completed", "failed"].includes(value.phase))
238
+ return null;
239
+ return value;
240
+ }
241
+ catch {
242
+ return null;
243
+ }
244
+ }
245
+ function writeJob(filePath, job) {
246
+ mkdirSync(dirname(filePath), { recursive: true });
247
+ const temporary = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
248
+ try {
249
+ writeFileSync(temporary, `${JSON.stringify(job, null, 2)}\n`, "utf8");
250
+ renameSync(temporary, filePath);
251
+ }
252
+ finally {
253
+ rmSync(temporary, { force: true });
254
+ }
255
+ }
256
+ function removeJob(filePath) {
257
+ rmSync(filePath, { force: true });
258
+ }
259
+ export let safeMaintenanceCoordinator = new SafeMaintenanceCoordinator();
260
+ export function _setSafeMaintenanceCoordinatorForTest(coordinator) {
261
+ safeMaintenanceCoordinator = coordinator;
262
+ }
263
+ export function _resetSafeMaintenanceCoordinatorForTest() {
264
+ safeMaintenanceCoordinator = new SafeMaintenanceCoordinator();
265
+ }
266
+ export function isSafeMaintenanceAdmissionClosed() {
267
+ return safeMaintenanceCoordinator.isAdmissionClosed();
268
+ }
269
+ export function beginSafeMaintenanceTrackedWork(label) {
270
+ return safeMaintenanceCoordinator.beginTrackedWork(label);
271
+ }
@@ -133,6 +133,18 @@ export function setUnifiedDisplayLoopHandle(h) {
133
133
  unifiedDisplayLoopHandle = h;
134
134
  }
135
135
  export const queuedMessages = new Map();
136
+ /** Includes prompt execution, finalization, auto-recovery reservations, and accepted queues. */
137
+ export function getSessionDrainSnapshot() {
138
+ const active = new Set([
139
+ ...activePrompts.keys(),
140
+ ...finalizingSessions,
141
+ ...autoRecoveryReservations,
142
+ ]);
143
+ return {
144
+ activeSessionIds: [...active].sort(),
145
+ queuedSessionIds: [...queuedMessages.keys()].sort(),
146
+ };
147
+ }
136
148
  export function enqueueMessage(sessionId, msg) {
137
149
  if (queuedMessages.has(sessionId))
138
150
  return false;
@@ -6,6 +6,7 @@ import { progressView } from "./progress/view.js";
6
6
  import { createAgentActivityTracker, formatAgentActivityTitle, updateAgentActivity, } from "./agent-activity.js";
7
7
  import { simplifyToolUse, simplifyToolResult } from "./simplify.js";
8
8
  import { logTrace } from "./trace.js";
9
+ import { appendExecutionTranscriptBlock, } from "./execution-transcript.js";
9
10
  import { createClaudeAdapter } from "./adapters/claude-adapter.js";
10
11
  import { createCursorAdapter } from "./adapters/cursor-adapter.js";
11
12
  import { createCodexAdapter } from "./adapters/codex-adapter.js";
@@ -698,6 +699,11 @@ export function pickFinalReply(state) {
698
699
  return state.finalCompleteText || state.finalText;
699
700
  }
700
701
  export function accumulateBlockContent(block, state, toolCallMap) {
702
+ if (state.transcript !== undefined || (block.type !== "agent_progress" && block.type !== "text_reset")) {
703
+ const transcriptState = { transcript: state.transcript ?? [] };
704
+ appendExecutionTranscriptBlock(block, transcriptState);
705
+ state.transcript = transcriptState.transcript;
706
+ }
701
707
  switch (block.type) {
702
708
  case "thinking":
703
709
  state.chunkCount++;
@@ -1145,6 +1151,7 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1145
1151
  refreshBusySessionAvatar(sessionId, tool, platform).catch(() => { });
1146
1152
  const state = {
1147
1153
  accumulatedContent: "",
1154
+ transcript: [],
1148
1155
  finalText: "",
1149
1156
  finalCompleteText: "",
1150
1157
  chunkCount: 0,
@@ -1196,6 +1203,7 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1196
1203
  status: "auto_ended",
1197
1204
  accumulatedContent: state.accumulatedContent,
1198
1205
  finalReply: pickFinalReply(state).trim(),
1206
+ transcript: state.transcript,
1199
1207
  activity: activityTracker.activity,
1200
1208
  chunkCount: state.chunkCount,
1201
1209
  turnCount: nextTurnCount,
@@ -1308,6 +1316,7 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1308
1316
  status: "running",
1309
1317
  accumulatedContent: state.accumulatedContent,
1310
1318
  finalReply: pickFinalReply(state),
1319
+ transcript: state.transcript,
1311
1320
  activity: activityTracker.activity,
1312
1321
  chunkCount: state.chunkCount,
1313
1322
  turnCount: nextTurnCount,
@@ -1406,6 +1415,7 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1406
1415
  status: finalStatus,
1407
1416
  accumulatedContent: state.accumulatedContent,
1408
1417
  finalReply: finalReplyToWrite,
1418
+ transcript: state.transcript,
1409
1419
  activity: activityTracker.activity,
1410
1420
  chunkCount: state.chunkCount,
1411
1421
  turnCount: nextTurnCount,
@@ -100,6 +100,7 @@ export function createEmptyStreamState(sessionId, cwd, tool, turnCount) {
100
100
  status: "running",
101
101
  accumulatedContent: "",
102
102
  finalReply: "",
103
+ transcript: [],
103
104
  activity: createAgentActivityTracker(now).activity,
104
105
  chunkCount: 0,
105
106
  turnCount,
@@ -17,6 +17,7 @@ import { AGENT_TEAM_PAGE_HTML } from "./agent-team/web/agent-team-page.js";
17
17
  export { AGENT_TEAM_PAGE_HTML } from "./agent-team/web/agent-team-page.js";
18
18
  import { buildWebUiUrl, createInternalRestartEnv, openWebUiInDefaultBrowser, } from "./startup-lifecycle.js";
19
19
  import { engineManager } from "./engines/engine-specs.js";
20
+ import { isSafeMaintenanceAdmissionClosed } from "./safe-maintenance.js";
20
21
  const PROJECT_ROOT = CHATCCC_PACKAGE_ROOT;
21
22
  const USER_DATA_DIR = join(homedir(), ".chatccc");
22
23
  const CONFIG_FILE = join(USER_DATA_DIR, "config.json");
@@ -656,6 +657,10 @@ async function handleEngineStatus(engineId, res) {
656
657
  }
657
658
  }
658
659
  async function handleEngineInstall(engineId, res) {
660
+ if (isSafeMaintenanceAdmissionClosed()) {
661
+ jsonReply(res, 409, { ok: false, error: "ChatCCC 正在等待安全维护,暂不接受新的依赖安装任务。" });
662
+ return;
663
+ }
659
664
  try {
660
665
  jsonReply(res, 202, { ok: true, job: await engineManager.startInstall(engineId) });
661
666
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.267",
3
+ "version": "0.2.268",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",