@keyou007/agent-bot 0.1.24-alpha.5 → 0.1.24-alpha.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +29 -3
- package/README.zh.md +29 -3
- package/SECURITY.md +12 -0
- package/config.example.yaml +5 -0
- package/dist/cli/controlProtocol.js.map +1 -1
- package/dist/cli/serverStatus.js +1 -0
- package/dist/cli/serverStatus.js.map +1 -1
- package/dist/cli.js +6 -1
- package/dist/cli.js.map +1 -1
- package/dist/codex/CodexEventMapper.js +1 -0
- package/dist/codex/CodexEventMapper.js.map +1 -1
- package/dist/codex/CodexRuntime.js +247 -120
- package/dist/codex/CodexRuntime.js.map +1 -1
- package/dist/codex/ProviderSwitch.js +12 -13
- package/dist/codex/ProviderSwitch.js.map +1 -1
- package/dist/codex/TraePlanEntry.js +70 -0
- package/dist/codex/TraePlanEntry.js.map +1 -0
- package/dist/config/AgentExecutionDefaultsStore.js +4 -0
- package/dist/config/AgentExecutionDefaultsStore.js.map +1 -1
- package/dist/config/loadConfig.js +4 -0
- package/dist/config/loadConfig.js.map +1 -1
- package/dist/config/schema.js +3 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/feishu/CardRenderer.js +92 -8
- package/dist/feishu/CardRenderer.js.map +1 -1
- package/dist/feishu/FeishuConnector.js +1 -0
- package/dist/feishu/FeishuConnector.js.map +1 -1
- package/dist/feishu/FeishuTurnPresenter.js +121 -17
- package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
- package/dist/index.js +57 -7
- package/dist/index.js.map +1 -1
- package/dist/local-files/LocalFileViewerServer.js +51 -4
- package/dist/local-files/LocalFileViewerServer.js.map +1 -1
- package/dist/local-files/TurnPreviewPage.js +7 -2
- package/dist/local-files/TurnPreviewPage.js.map +1 -1
- package/dist/presentation/TurnStateReducer.js +41 -6
- package/dist/presentation/TurnStateReducer.js.map +1 -1
- package/dist/proxy/ProxySessionController.js +620 -214
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/proxy/SessionOperationQueue.js +14 -0
- package/dist/proxy/SessionOperationQueue.js.map +1 -0
- package/dist/runtime/AcpRuntimeAdapter.js +4 -2
- package/dist/runtime/AcpRuntimeAdapter.js.map +1 -1
- package/dist/state/StateStore.js +22 -0
- package/dist/state/StateStore.js.map +1 -1
- package/dist/state/TurnPreviewJournal.js +19 -7
- package/dist/state/TurnPreviewJournal.js.map +1 -1
- package/dist/state/migrations.js +6 -0
- package/dist/state/migrations.js.map +1 -1
- package/dist/supervision/SafeRestartNotifier.js +33 -1
- package/dist/supervision/SafeRestartNotifier.js.map +1 -1
- package/dist/supervision/SafeRestartScheduler.js +22 -6
- package/dist/supervision/SafeRestartScheduler.js.map +1 -1
- package/dist/terminal/TerminalCards.js +13 -0
- package/dist/terminal/TerminalCards.js.map +1 -0
- package/dist/terminal/TerminalCommandTracker.js +74 -0
- package/dist/terminal/TerminalCommandTracker.js.map +1 -0
- package/dist/terminal/TerminalLinks.js +76 -0
- package/dist/terminal/TerminalLinks.js.map +1 -0
- package/dist/terminal/TerminalPage.js +212 -0
- package/dist/terminal/TerminalPage.js.map +1 -0
- package/dist/terminal/TerminalProcess.js +95 -0
- package/dist/terminal/TerminalProcess.js.map +1 -0
- package/dist/terminal/TerminalServer.js +676 -0
- package/dist/terminal/TerminalServer.js.map +1 -0
- package/docs/technical-reference.md +48 -0
- package/docs/technical-reference.zh.md +48 -0
- package/npm-shrinkwrap.json +55 -5
- package/package.json +11 -4
- package/skills/agent-bot/SKILL.md +11 -2
- package/src/cli/controlProtocol.ts +2 -1
- package/src/cli/serverStatus.ts +1 -0
- package/src/cli.ts +6 -1
- package/src/codex/CodexEventMapper.ts +1 -0
- package/src/codex/CodexRuntime.ts +244 -128
- package/src/codex/ProviderSwitch.ts +11 -12
- package/src/codex/TraePlanEntry.ts +63 -0
- package/src/config/AgentExecutionDefaultsStore.ts +2 -0
- package/src/config/loadConfig.ts +4 -0
- package/src/config/schema.ts +5 -1
- package/src/feishu/CardRenderer.ts +109 -8
- package/src/feishu/FeishuConnector.ts +1 -0
- package/src/feishu/FeishuTurnPresenter.ts +108 -16
- package/src/feishu/types.ts +1 -0
- package/src/index.ts +56 -8
- package/src/local-files/LocalFileViewerServer.ts +48 -4
- package/src/local-files/TurnPreviewPage.ts +7 -2
- package/src/presentation/TurnStateReducer.ts +46 -7
- package/src/presentation/turnViewTypes.ts +5 -0
- package/src/proxy/ProxySessionController.ts +629 -211
- package/src/proxy/SessionOperationQueue.ts +13 -0
- package/src/runtime/AcpRuntimeAdapter.ts +5 -3
- package/src/runtime/types.ts +8 -3
- package/src/state/StateStore.ts +38 -1
- package/src/state/TurnPreviewJournal.ts +19 -7
- package/src/state/migrations.ts +6 -0
- package/src/supervision/SafeRestartNotifier.ts +35 -2
- package/src/supervision/SafeRestartScheduler.ts +25 -6
- package/src/terminal/TerminalCards.ts +18 -0
- package/src/terminal/TerminalCommandTracker.ts +74 -0
- package/src/terminal/TerminalLinks.ts +61 -0
- package/src/terminal/TerminalPage.ts +213 -0
- package/src/terminal/TerminalProcess.ts +93 -0
- package/src/terminal/TerminalServer.ts +551 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Serialize settings changes and turn starts across every route bound to a task.
|
|
2
|
+
export class SessionOperationQueue {
|
|
3
|
+
private readonly tails = new Map<string, Promise<unknown>>();
|
|
4
|
+
|
|
5
|
+
run<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
|
|
6
|
+
const previous = this.tails.get(sessionId) ?? Promise.resolve();
|
|
7
|
+
const next = previous.catch(() => undefined).then(operation);
|
|
8
|
+
this.tails.set(sessionId, next);
|
|
9
|
+
return next.finally(() => {
|
|
10
|
+
if (this.tails.get(sessionId) === next) this.tails.delete(sessionId);
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -29,7 +29,7 @@ export class AcpRuntimeAdapter implements AgentRuntime {
|
|
|
29
29
|
private readonly listeners = new Set<(event: RuntimeEvent) => void>();
|
|
30
30
|
private readonly approvals = new Map<
|
|
31
31
|
string,
|
|
32
|
-
{ sessionId: string; resolve: (value: JsonValue) => void; optionIds: Map<ApprovalDecision, string> }
|
|
32
|
+
{ sessionId: string; turnId: string; resolve: (value: JsonValue) => void; optionIds: Map<ApprovalDecision, string> }
|
|
33
33
|
>();
|
|
34
34
|
|
|
35
35
|
constructor(private readonly acp: AcpSessionManager) {}
|
|
@@ -59,11 +59,12 @@ export class AcpRuntimeAdapter implements AgentRuntime {
|
|
|
59
59
|
optionIds.set("decline", option.optionId);
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
-
|
|
62
|
+
const turnId = active?.turnId ?? "unknown";
|
|
63
|
+
this.approvals.set(requestId, { sessionId: input.localSessionId, turnId, resolve, optionIds });
|
|
63
64
|
this.emit({
|
|
64
65
|
type: "approval_requested",
|
|
65
66
|
sessionId: input.localSessionId,
|
|
66
|
-
turnId
|
|
67
|
+
turnId,
|
|
67
68
|
request: {
|
|
68
69
|
id: requestId,
|
|
69
70
|
title: typeof params.toolCall.title === "string" ? params.toolCall.title : "ACP permission request",
|
|
@@ -185,6 +186,7 @@ export class AcpRuntimeAdapter implements AgentRuntime {
|
|
|
185
186
|
this.approvals.delete(requestId);
|
|
186
187
|
const optionId = pending.optionIds.get(decision) ?? pending.optionIds.get(decision === "cancel" ? "decline" : "accept");
|
|
187
188
|
pending.resolve({ outcome: { outcome: "selected", optionId: optionId ?? decision } });
|
|
189
|
+
this.emit({ type: "approval_resolved", sessionId, turnId: pending.turnId, requestId, decision });
|
|
188
190
|
}
|
|
189
191
|
|
|
190
192
|
async listModels(): Promise<ModelOption[]> {
|
package/src/runtime/types.ts
CHANGED
|
@@ -34,6 +34,7 @@ export interface RuntimeGoalUpdate {
|
|
|
34
34
|
export type RuntimePrompt = string | {
|
|
35
35
|
text: string;
|
|
36
36
|
localImagePaths?: string[];
|
|
37
|
+
collaborationMode?: "default";
|
|
37
38
|
};
|
|
38
39
|
|
|
39
40
|
export interface PlanStep {
|
|
@@ -60,6 +61,7 @@ export interface ToolState {
|
|
|
60
61
|
export interface ApprovalRequest {
|
|
61
62
|
id: string;
|
|
62
63
|
kind?: "mode_change";
|
|
64
|
+
automatic?: boolean;
|
|
63
65
|
title: string;
|
|
64
66
|
command?: string;
|
|
65
67
|
reason?: string;
|
|
@@ -108,8 +110,8 @@ export type AgentEvent =
|
|
|
108
110
|
| { type: "tool_updated"; sessionId: string; turnId: string; tool: ToolState }
|
|
109
111
|
| { type: "tool_output_delta"; sessionId: string; turnId: string; toolId: string; delta: string }
|
|
110
112
|
| { type: "approval_requested"; sessionId: string; turnId: string; request: ApprovalRequest }
|
|
111
|
-
| { type: "approval_resolved"; sessionId: string; turnId: string; requestId: string; decision: ApprovalDecision }
|
|
112
|
-
| { type: "turn_completed"; sessionId: string; turnId: string; finalResponse: string; durationMs?: number }
|
|
113
|
+
| { type: "approval_resolved"; sessionId: string; turnId: string; requestId: string; decision: ApprovalDecision; automatic?: boolean }
|
|
114
|
+
| { type: "turn_completed"; sessionId: string; turnId: string; finalResponse: string; durationMs?: number; approvedPlan?: boolean }
|
|
113
115
|
| { type: "turn_cancelled"; sessionId: string; turnId: string }
|
|
114
116
|
| { type: "turn_failed"; sessionId: string; turnId: string; message: string };
|
|
115
117
|
|
|
@@ -284,6 +286,9 @@ export interface AgentRuntime {
|
|
|
284
286
|
getThreadWriterLockPath?(remoteSessionId: string): string | undefined;
|
|
285
287
|
createSession(input: CreateRuntimeSessionInput): Promise<RuntimeSession>;
|
|
286
288
|
resumeSession(input: ResumeRuntimeSessionInput): Promise<RuntimeSession>;
|
|
289
|
+
// Hydrate the saved same-thread snapshot without contacting the old Provider.
|
|
290
|
+
// The runtime must verify a resume before any subsequent turn or remote operation.
|
|
291
|
+
prepareSessionForSettings?(input: ResumeRuntimeSessionInput): Promise<RuntimeSession>;
|
|
287
292
|
forkSession?(input: ForkRuntimeSessionInput): Promise<RuntimeSession>;
|
|
288
293
|
getSession(localSessionId: string): RuntimeSession | undefined;
|
|
289
294
|
// Drop local monitoring only; do not interrupt, unsubscribe, or archive remote work.
|
|
@@ -302,7 +307,7 @@ export interface AgentRuntime {
|
|
|
302
307
|
steerTurn(sessionId: string, turnId: string, prompt: RuntimePrompt): Promise<void>;
|
|
303
308
|
cancelTurn(sessionId: string, turnId: string): Promise<void>;
|
|
304
309
|
interruptRemoteTurn?(remoteSessionId: string, turnId: string): Promise<void>;
|
|
305
|
-
archiveRemoteSession?(remoteSessionId: string): Promise<void>;
|
|
310
|
+
archiveRemoteSession?(remoteSessionId: string, options?: { allowUnmaterialized?: boolean }): Promise<void>;
|
|
306
311
|
closeSession(sessionId: string): Promise<void>;
|
|
307
312
|
setTitle?(sessionId: string, title: string): Promise<void>;
|
|
308
313
|
getGoal?(sessionId: string): Promise<RuntimeGoal | undefined>;
|
package/src/state/StateStore.ts
CHANGED
|
@@ -6,7 +6,17 @@ import path from "node:path";
|
|
|
6
6
|
import Database from "better-sqlite3";
|
|
7
7
|
import { migrations } from "./migrations.js";
|
|
8
8
|
import type { UpdateCheckSchedule, UpdateNotice } from "../updates/types.js";
|
|
9
|
-
import type { ConversationTurn } from "../runtime/types.js";
|
|
9
|
+
import type { ConversationTurn, RuntimeExecutionSettings } from "../runtime/types.js";
|
|
10
|
+
|
|
11
|
+
export interface PendingProviderSettings {
|
|
12
|
+
localSessionId: string;
|
|
13
|
+
contextKey: string;
|
|
14
|
+
settings: RuntimeExecutionSettings;
|
|
15
|
+
messageId?: string;
|
|
16
|
+
replyMessageId?: string;
|
|
17
|
+
activeTab?: "provider" | "model" | "thinking" | "permission" | "agent";
|
|
18
|
+
error?: string;
|
|
19
|
+
}
|
|
10
20
|
|
|
11
21
|
export type SessionStatus = "starting" | "ready" | "running" | "closed" | "failed";
|
|
12
22
|
|
|
@@ -300,6 +310,27 @@ export class StateStore {
|
|
|
300
310
|
return row ? JSON.parse(row.value_json) as UpdateCheckSchedule : undefined;
|
|
301
311
|
}
|
|
302
312
|
|
|
313
|
+
getPendingProviderSettings(localSessionId: string): PendingProviderSettings | undefined {
|
|
314
|
+
const row = this.db.prepare("SELECT value_json FROM pending_provider_settings WHERE local_session_id = ?")
|
|
315
|
+
.get(localSessionId) as { value_json: string } | undefined;
|
|
316
|
+
return row ? JSON.parse(row.value_json) as PendingProviderSettings : undefined;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
savePendingProviderSettings(pending: PendingProviderSettings): void {
|
|
320
|
+
this.db.prepare(`INSERT INTO pending_provider_settings (local_session_id, value_json) VALUES (?, ?)
|
|
321
|
+
ON CONFLICT(local_session_id) DO UPDATE SET value_json = excluded.value_json`)
|
|
322
|
+
.run(pending.localSessionId, JSON.stringify(pending));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
deletePendingProviderSettings(localSessionId: string): void {
|
|
326
|
+
this.db.prepare("DELETE FROM pending_provider_settings WHERE local_session_id = ?").run(localSessionId);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
listPendingProviderSettings(): PendingProviderSettings[] {
|
|
330
|
+
const rows = this.db.prepare("SELECT value_json FROM pending_provider_settings").all() as Array<{ value_json: string }>;
|
|
331
|
+
return rows.map((row) => JSON.parse(row.value_json) as PendingProviderSettings);
|
|
332
|
+
}
|
|
333
|
+
|
|
303
334
|
saveUpdateCheckSchedule(schedule: UpdateCheckSchedule): void {
|
|
304
335
|
this.db.prepare(`INSERT INTO update_check_schedule (id, value_json) VALUES (1, ?)
|
|
305
336
|
ON CONFLICT(id) DO UPDATE SET value_json = excluded.value_json`).run(JSON.stringify(schedule));
|
|
@@ -684,6 +715,11 @@ export class StateStore {
|
|
|
684
715
|
return row ? mapTurnAttempt(row) : undefined;
|
|
685
716
|
}
|
|
686
717
|
|
|
718
|
+
hasTurnAttemptsForSession(localSessionId: string): boolean {
|
|
719
|
+
return Boolean(this.db.prepare("SELECT 1 FROM turn_attempts WHERE local_session_id = ? LIMIT 1")
|
|
720
|
+
.get(localSessionId));
|
|
721
|
+
}
|
|
722
|
+
|
|
687
723
|
findIncompleteTurnAttemptForSession(localSessionId: string): TurnAttemptRecord | undefined {
|
|
688
724
|
const row = this.db.prepare(`
|
|
689
725
|
SELECT * FROM turn_attempts
|
|
@@ -982,6 +1018,7 @@ export class StateStore {
|
|
|
982
1018
|
archiveSession(localSessionId: string): void {
|
|
983
1019
|
const now = new Date().toISOString();
|
|
984
1020
|
const archive = this.db.transaction(() => {
|
|
1021
|
+
this.deletePendingProviderSettings(localSessionId);
|
|
985
1022
|
this.db.prepare(`
|
|
986
1023
|
UPDATE sessions
|
|
987
1024
|
SET status = 'closed', updated_at = ?
|
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createHash, type Hash } from "node:crypto";
|
|
4
4
|
import type { AgentEvent, ToolState } from "../runtime/types.js";
|
|
5
|
-
import { reduceTurnEvent } from "../presentation/TurnStateReducer.js";
|
|
5
|
+
import { approvalActivityId, continueTurnView, reduceTurnEvent } from "../presentation/TurnStateReducer.js";
|
|
6
6
|
import type { TurnViewState } from "../presentation/turnViewTypes.js";
|
|
7
7
|
|
|
8
8
|
type TextChange = { key: string; field: string; append: boolean; text: string };
|
|
@@ -10,6 +10,7 @@ export type PreviewRecord =
|
|
|
10
10
|
| { kind: "seed"; state: TurnViewState }
|
|
11
11
|
| { kind: "state"; patch: Partial<TurnViewState> }
|
|
12
12
|
| { kind: "user"; id: string; text: string; images?: string[] }
|
|
13
|
+
| { kind: "handoff"; turnId: string; startedAt: number }
|
|
13
14
|
| { kind: "event"; event: AgentEvent; texts: TextChange[] };
|
|
14
15
|
type Stream = { length: number; hash: Hash };
|
|
15
16
|
type Writer = { seeded?: boolean; pending: string[]; bytes: number; dirtyDisk?: boolean; streams: Map<string, Stream>; timings: Map<string, number>; timer?: NodeJS.Timeout; error?: unknown };
|
|
@@ -29,9 +30,10 @@ export class TurnPreviewJournal {
|
|
|
29
30
|
has(turnId: string): boolean { return this.writers.has(turnId) || fs.existsSync(this.file(turnId)); }
|
|
30
31
|
|
|
31
32
|
seed(state: TurnViewState): void {
|
|
32
|
-
const
|
|
33
|
+
const turnId = state.previewTurnId ?? state.turnId;
|
|
34
|
+
const writer = this.writer(turnId);
|
|
33
35
|
if (writer.seeded) return;
|
|
34
|
-
this.append(
|
|
36
|
+
this.append(turnId, { kind: "seed", state: { ...state, previewJournal: true } });
|
|
35
37
|
writer.seeded = true;
|
|
36
38
|
for (const change of seedTexts(state)) this.track(writer, change);
|
|
37
39
|
}
|
|
@@ -42,15 +44,19 @@ export class TurnPreviewJournal {
|
|
|
42
44
|
const { activities, reasoningItems, fullToolOutputs, fullToolErrors, toolStatuses,
|
|
43
45
|
completedTools, failedTools, activeTool, assistantText, progressText, plan, fileSummary,
|
|
44
46
|
activitiesTruncated, previewCursor, ...patch } = state;
|
|
45
|
-
this.append(state.turnId, { kind: "state", patch });
|
|
47
|
+
this.append(state.previewTurnId ?? state.turnId, { kind: "state", patch });
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
user(turnId: string, id: string, text: string, images?: string[]): void {
|
|
49
51
|
this.append(turnId, { kind: "user", id, text, images });
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
handoff(journalId: string, turnId: string, startedAt: number): void {
|
|
55
|
+
this.append(journalId, { kind: "handoff", turnId, startedAt });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
event(event: AgentEvent, journalId = event.turnId): void {
|
|
59
|
+
const writer = this.writer(journalId);
|
|
54
60
|
const texts: TextChange[] = [];
|
|
55
61
|
const copy = { ...event } as AgentEvent;
|
|
56
62
|
const capture = (key: string, field: string, text: string, append = false) => {
|
|
@@ -96,7 +102,7 @@ export class TurnPreviewJournal {
|
|
|
96
102
|
});
|
|
97
103
|
}
|
|
98
104
|
}
|
|
99
|
-
this.append(
|
|
105
|
+
this.append(journalId, { kind: "event", event: copy, texts });
|
|
100
106
|
}
|
|
101
107
|
|
|
102
108
|
flush(turnId?: string, durable = false): void {
|
|
@@ -320,6 +326,10 @@ export class TurnPreviewProjection {
|
|
|
320
326
|
return;
|
|
321
327
|
}
|
|
322
328
|
if (!this.state) return;
|
|
329
|
+
if (record.kind === "handoff") {
|
|
330
|
+
this.state = continueTurnView(this.state, record.turnId, record.startedAt);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
323
333
|
if (record.kind === "state") {
|
|
324
334
|
this.state = { ...this.state, ...record.patch, historyDetail: "full", previewJournal: true };
|
|
325
335
|
return;
|
|
@@ -376,6 +386,8 @@ export class TurnPreviewProjection {
|
|
|
376
386
|
if (event.type === "context_compaction") {
|
|
377
387
|
for (const a of this.state.activities) if (!old.activities.includes(a)) this.changed.add(a.id);
|
|
378
388
|
}
|
|
389
|
+
if (event.type === "approval_resolved") this.changed.add(approvalActivityId(event.turnId, event.requestId));
|
|
390
|
+
if (old.approval && !this.state.approval) this.changed.add(approvalActivityId(event.turnId, old.approval.id));
|
|
379
391
|
if (this.details !== true && this.state.reasoningItems) this.state.reasoningItems = this.state.reasoningItems.map((r) => ({ ...r,
|
|
380
392
|
summary: r.summary.map((t) => this.wantsFull(`reasoning:${r.itemId}:`) ? t : t.slice(0, 6000)), content: r.content.map((t) => this.wantsFull(`reasoning:${r.itemId}:`) ? t : t.slice(0, 6000)) }));
|
|
381
393
|
this.state.activitiesTruncated = false;
|
package/src/state/migrations.ts
CHANGED
|
@@ -217,4 +217,10 @@ export const migrations = [
|
|
|
217
217
|
PRIMARY KEY (local_session_id, remote_session_id, context_key, message_id)
|
|
218
218
|
);
|
|
219
219
|
`,
|
|
220
|
+
`
|
|
221
|
+
CREATE TABLE IF NOT EXISTS pending_provider_settings (
|
|
222
|
+
local_session_id TEXT PRIMARY KEY,
|
|
223
|
+
value_json TEXT NOT NULL
|
|
224
|
+
);
|
|
225
|
+
`,
|
|
220
226
|
] as const;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Logger } from "pino";
|
|
2
2
|
import { CardRenderer } from "../feishu/CardRenderer.js";
|
|
3
3
|
import { isThreadContextKey } from "../feishu/contextKey.js";
|
|
4
|
-
import type { FeishuOutbound } from "../feishu/types.js";
|
|
4
|
+
import type { CardAction, FeishuOutbound } from "../feishu/types.js";
|
|
5
5
|
import type { StateStore } from "../state/StateStore.js";
|
|
6
6
|
import type { RestartNotificationTarget, SafeRestartStatus } from "./SafeRestartScheduler.js";
|
|
7
7
|
|
|
@@ -9,11 +9,15 @@ const DEFAULT_INITIAL_CARD_DELAY_MS = 3_000;
|
|
|
9
9
|
|
|
10
10
|
export interface SafeRestartNotifierOptions {
|
|
11
11
|
initialCardDelayMs?: number;
|
|
12
|
+
ownerId?(): string | undefined;
|
|
13
|
+
isCurrentSchedule?(scheduleId: number): boolean;
|
|
14
|
+
stopTerminal?(terminalId: string, userId: string): Promise<boolean>;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
export class SafeRestartNotifier {
|
|
15
18
|
private readonly messageIds = new Map<string, string>();
|
|
16
19
|
private readonly cardHashes = new Map<string, string>();
|
|
20
|
+
private readonly cardTerminalIds = new Map<string, Set<string>>();
|
|
17
21
|
private readonly notificationTargets = new Map<string, RestartNotificationTarget>();
|
|
18
22
|
private activeScheduleId?: number;
|
|
19
23
|
private terminalScheduleId?: number;
|
|
@@ -55,6 +59,7 @@ export class SafeRestartNotifier {
|
|
|
55
59
|
this.activeScheduleId = status.scheduleId;
|
|
56
60
|
this.messageIds.clear();
|
|
57
61
|
this.cardHashes.clear();
|
|
62
|
+
this.cardTerminalIds.clear();
|
|
58
63
|
this.notificationTargets.clear();
|
|
59
64
|
this.clearInitialCardTimer();
|
|
60
65
|
this.pendingInitialStatus = status;
|
|
@@ -90,6 +95,25 @@ export class SafeRestartNotifier {
|
|
|
90
95
|
return [...this.notificationTargets.values()].map((target) => ({ ...target }));
|
|
91
96
|
}
|
|
92
97
|
|
|
98
|
+
async stopTerminal(action: CardAction): Promise<string> {
|
|
99
|
+
const ownerId = this.options.ownerId?.();
|
|
100
|
+
if (!ownerId || action.userId !== ownerId) throw new Error("仅 AgentBot 所有者可结束终端。");
|
|
101
|
+
const scheduleId = Number(action.value.scheduleId);
|
|
102
|
+
const terminalId = typeof action.value.terminalId === "string" ? action.value.terminalId : "";
|
|
103
|
+
if (!Number.isSafeInteger(scheduleId) || scheduleId <= 0 || !/^[a-f0-9]{32}$/u.test(terminalId)) {
|
|
104
|
+
throw new Error("安全重启卡片无效,请使用最新的状态卡片。");
|
|
105
|
+
}
|
|
106
|
+
if (scheduleId !== this.activeScheduleId || !this.options.isCurrentSchedule?.(scheduleId)
|
|
107
|
+
|| !this.lastStatus || ["cancelled", "restarting"].includes(this.lastStatus.phase)
|
|
108
|
+
|| !action.messageId || this.messageIds.get(action.contextKey) !== action.messageId
|
|
109
|
+
|| !this.cardTerminalIds.get(action.contextKey)?.has(terminalId)) {
|
|
110
|
+
throw new Error("该安全重启卡片或终端操作已失效,请查看最新状态卡片。");
|
|
111
|
+
}
|
|
112
|
+
if (!this.options.stopTerminal) throw new Error("网页终端未启用。");
|
|
113
|
+
const stopped = await this.options.stopTerminal(terminalId, ownerId);
|
|
114
|
+
return stopped ? "已请求结束对应终端,安全重启将继续等待其余阻塞项。" : "该终端已结束或正在关闭,无需重复操作。";
|
|
115
|
+
}
|
|
116
|
+
|
|
93
117
|
private publishPendingInitialStatus(): Promise<void> {
|
|
94
118
|
this.clearInitialCardTimer();
|
|
95
119
|
const status = this.pendingInitialStatus;
|
|
@@ -132,6 +156,9 @@ export class SafeRestartNotifier {
|
|
|
132
156
|
phase: status.phase,
|
|
133
157
|
remainingMs: status.remainingMs,
|
|
134
158
|
pendingFinalDeliveries: status.activity.pendingFinalDeliveries,
|
|
159
|
+
activeTerminals: status.activity.activeTerminals,
|
|
160
|
+
terminalBlockers: status.activity.terminalBlockers,
|
|
161
|
+
contextKey,
|
|
135
162
|
waitingTasks,
|
|
136
163
|
});
|
|
137
164
|
const cardHash = JSON.stringify(card);
|
|
@@ -140,12 +167,17 @@ export class SafeRestartNotifier {
|
|
|
140
167
|
if (this.cardHashes.get(contextKey) === cardHash) return;
|
|
141
168
|
try {
|
|
142
169
|
await this.outbound.updateInteractiveCard(messageId, card);
|
|
143
|
-
this.
|
|
170
|
+
if (this.activeScheduleId === status.scheduleId && this.messageIds.get(contextKey) === messageId) {
|
|
171
|
+
this.cardHashes.set(contextKey, cardHash);
|
|
172
|
+
this.cardTerminalIds.set(contextKey, new Set((status.activity.terminalBlockers ?? []).map((terminal) => terminal.terminalId)));
|
|
173
|
+
}
|
|
144
174
|
return;
|
|
145
175
|
} catch (error) {
|
|
146
176
|
this.logger.warn({ error, contextKey, messageId }, "Failed to update safe restart status card; sending a replacement.");
|
|
177
|
+
if (this.activeScheduleId !== status.scheduleId) return;
|
|
147
178
|
this.messageIds.delete(contextKey);
|
|
148
179
|
this.cardHashes.delete(contextKey);
|
|
180
|
+
this.cardTerminalIds.delete(contextKey);
|
|
149
181
|
}
|
|
150
182
|
}
|
|
151
183
|
try {
|
|
@@ -169,6 +201,7 @@ export class SafeRestartNotifier {
|
|
|
169
201
|
if (this.activeScheduleId === status.scheduleId) {
|
|
170
202
|
this.messageIds.set(contextKey, created);
|
|
171
203
|
this.cardHashes.set(contextKey, cardHash);
|
|
204
|
+
this.cardTerminalIds.set(contextKey, new Set((status.activity.terminalBlockers ?? []).map((terminal) => terminal.terminalId)));
|
|
172
205
|
} else {
|
|
173
206
|
await this.stopCard(created, status, reason ?? status.reason);
|
|
174
207
|
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import type { ActiveTerminal } from "../terminal/TerminalServer.js";
|
|
2
|
+
|
|
1
3
|
export interface ServerActivityState {
|
|
4
|
+
activeTerminals?: number;
|
|
5
|
+
terminalBlockers?: ActiveTerminal[];
|
|
2
6
|
runningSessions: number;
|
|
3
7
|
pendingFinalDeliveries: number;
|
|
4
8
|
latestInboundAt?: string;
|
|
@@ -49,6 +53,7 @@ export class SafeRestartScheduler {
|
|
|
49
53
|
private idleInboundAt?: string;
|
|
50
54
|
private scheduleId = 0;
|
|
51
55
|
private restartImmediatelyIfIdle = false;
|
|
56
|
+
private restartingNow = false;
|
|
52
57
|
|
|
53
58
|
constructor(private readonly options: SafeRestartSchedulerOptions) {}
|
|
54
59
|
|
|
@@ -56,10 +61,18 @@ export class SafeRestartScheduler {
|
|
|
56
61
|
return Boolean(this.reason);
|
|
57
62
|
}
|
|
58
63
|
|
|
64
|
+
get restarting(): boolean {
|
|
65
|
+
return this.restartingNow;
|
|
66
|
+
}
|
|
67
|
+
|
|
59
68
|
get pendingReason(): string | undefined {
|
|
60
69
|
return this.reason;
|
|
61
70
|
}
|
|
62
71
|
|
|
72
|
+
isCurrentSchedule(scheduleId: number): boolean {
|
|
73
|
+
return this.scheduled && !this.restartingNow && this.scheduleId === scheduleId;
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
schedule(
|
|
64
77
|
reason: string,
|
|
65
78
|
notificationTarget?: RestartNotificationTarget,
|
|
@@ -131,14 +144,14 @@ export class SafeRestartScheduler {
|
|
|
131
144
|
if (!reason) return;
|
|
132
145
|
const scheduleId = this.scheduleId;
|
|
133
146
|
const state = this.options.readActivity();
|
|
134
|
-
if (state
|
|
147
|
+
if (hasActiveWork(state) || state.pendingFinalDeliveries > 0) {
|
|
135
148
|
this.idleSince = undefined;
|
|
136
149
|
this.idleInboundAt = undefined;
|
|
137
150
|
await this.emitStatus({
|
|
138
151
|
scheduleId,
|
|
139
152
|
reason,
|
|
140
153
|
notificationTargets: this.notificationTargetsSnapshot(),
|
|
141
|
-
phase: state
|
|
154
|
+
phase: hasActiveWork(state) ? "waiting_tasks" : "waiting_delivery",
|
|
142
155
|
activity: state,
|
|
143
156
|
});
|
|
144
157
|
return;
|
|
@@ -176,18 +189,18 @@ export class SafeRestartScheduler {
|
|
|
176
189
|
}
|
|
177
190
|
const confirmed = this.options.readActivity();
|
|
178
191
|
if (
|
|
179
|
-
confirmed
|
|
192
|
+
hasActiveWork(confirmed)
|
|
180
193
|
|| confirmed.pendingFinalDeliveries > 0
|
|
181
194
|
|| (confirmed.latestInboundAt ?? "") !== this.idleInboundAt
|
|
182
195
|
) {
|
|
183
196
|
this.idleSince = undefined;
|
|
184
197
|
this.idleInboundAt = undefined;
|
|
185
|
-
if (confirmed
|
|
198
|
+
if (hasActiveWork(confirmed) || confirmed.pendingFinalDeliveries > 0) {
|
|
186
199
|
await this.emitStatus({
|
|
187
200
|
scheduleId,
|
|
188
201
|
reason,
|
|
189
202
|
notificationTargets: this.notificationTargetsSnapshot(),
|
|
190
|
-
phase: confirmed
|
|
203
|
+
phase: hasActiveWork(confirmed) ? "waiting_tasks" : "waiting_delivery",
|
|
191
204
|
activity: confirmed,
|
|
192
205
|
});
|
|
193
206
|
} else {
|
|
@@ -206,6 +219,7 @@ export class SafeRestartScheduler {
|
|
|
206
219
|
}
|
|
207
220
|
|
|
208
221
|
private async restart(reason: string, scheduleId: number, activity: ServerActivityState): Promise<void> {
|
|
222
|
+
this.restartingNow = true;
|
|
209
223
|
const notificationTargets = this.notificationTargetsSnapshot();
|
|
210
224
|
this.clear();
|
|
211
225
|
await this.emitStatus({
|
|
@@ -216,7 +230,8 @@ export class SafeRestartScheduler {
|
|
|
216
230
|
activity,
|
|
217
231
|
remainingMs: 0,
|
|
218
232
|
});
|
|
219
|
-
await this.options.onReady(reason, notificationTargets);
|
|
233
|
+
try { await this.options.onReady(reason, notificationTargets); }
|
|
234
|
+
finally { this.restartingNow = false; }
|
|
220
235
|
}
|
|
221
236
|
|
|
222
237
|
private requestPoll(): void {
|
|
@@ -269,6 +284,10 @@ export class SafeRestartScheduler {
|
|
|
269
284
|
}
|
|
270
285
|
}
|
|
271
286
|
|
|
287
|
+
function hasActiveWork(state: ServerActivityState): boolean {
|
|
288
|
+
return state.runningSessions > 0 || (state.activeTerminals ?? 0) > 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
272
291
|
function normalizedNotificationTarget(
|
|
273
292
|
value: RestartNotificationTarget | undefined,
|
|
274
293
|
reason: string,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { CardRenderer } from "../feishu/CardRenderer.js";
|
|
2
|
+
import { isThreadContextKey } from "../feishu/contextKey.js";
|
|
3
|
+
import type { FeishuOutbound } from "../feishu/types.js";
|
|
4
|
+
import type { TerminalCardView } from "./TerminalServer.js";
|
|
5
|
+
|
|
6
|
+
export async function sendTerminalAuthorization(
|
|
7
|
+
outbound: Pick<FeishuOutbound, "sendInteractiveCard" | "replyInteractiveCard">,
|
|
8
|
+
view: TerminalCardView,
|
|
9
|
+
): Promise<string | undefined> {
|
|
10
|
+
const card = new CardRenderer().renderTerminalAuthorization(view);
|
|
11
|
+
if (isThreadContextKey(view.contextKey)) {
|
|
12
|
+
if (!outbound.replyInteractiveCard || !view.commandCardMessageId) {
|
|
13
|
+
throw new Error("无法向原话题发送终端授权卡,请重新打开命令卡片。");
|
|
14
|
+
}
|
|
15
|
+
return outbound.replyInteractiveCard(view.contextKey, { messageId: view.commandCardMessageId, replyInThread: true }, card);
|
|
16
|
+
}
|
|
17
|
+
return outbound.sendInteractiveCard(view.contextKey, card);
|
|
18
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export interface TerminalCommandState {
|
|
4
|
+
status: "unknown" | "idle" | "running";
|
|
5
|
+
command?: string;
|
|
6
|
+
source?: "shell" | "process";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class TerminalCommandTracker {
|
|
10
|
+
readonly nonce = randomBytes(16).toString("hex");
|
|
11
|
+
private readonly prefix = String.fromCharCode(27) + "]777;agentbot;" + this.nonce + ";";
|
|
12
|
+
private pending = "";
|
|
13
|
+
private current: TerminalCommandState = { status: "unknown" };
|
|
14
|
+
|
|
15
|
+
snapshot(): TerminalCommandState { return { ...this.current }; }
|
|
16
|
+
|
|
17
|
+
consume(data: string): string {
|
|
18
|
+
this.pending += data;
|
|
19
|
+
let output = "";
|
|
20
|
+
while (this.pending) {
|
|
21
|
+
const start = this.pending.indexOf(this.prefix);
|
|
22
|
+
if (start < 0) {
|
|
23
|
+
let retained = Math.min(this.prefix.length - 1, this.pending.length);
|
|
24
|
+
while (retained && !this.prefix.startsWith(this.pending.slice(-retained))) retained--;
|
|
25
|
+
output += this.pending.slice(0, this.pending.length - retained);
|
|
26
|
+
this.pending = this.pending.slice(this.pending.length - retained);
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
output += this.pending.slice(0, start); this.pending = this.pending.slice(start);
|
|
30
|
+
const end = this.pending.indexOf("\u0007", this.prefix.length);
|
|
31
|
+
if (end < 0 && this.pending.length <= 32_768) break;
|
|
32
|
+
if (end < 0 || end > 32_768) {
|
|
33
|
+
output += this.pending[0]; this.pending = this.pending.slice(1); continue;
|
|
34
|
+
}
|
|
35
|
+
const payload = this.pending.slice(this.prefix.length, end);
|
|
36
|
+
if (payload === "idle") this.current = { status: "idle", source: "shell" };
|
|
37
|
+
else if (payload.startsWith("command;") && /^[A-Za-z0-9+/]*={0,2}$/u.test(payload.slice(8))) {
|
|
38
|
+
const command = Buffer.from(payload.slice(8), "base64").toString("utf8")
|
|
39
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/gu, "").trim();
|
|
40
|
+
this.current = { status: "running", source: "shell", command: command.slice(0, 2_000) || undefined };
|
|
41
|
+
} else this.current = { status: "unknown" };
|
|
42
|
+
this.pending = this.pending.slice(end + 1);
|
|
43
|
+
}
|
|
44
|
+
return output;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function powerShellTrackingScript(nonce: string): string {
|
|
49
|
+
if (!/^[a-f0-9]{32}$/u.test(nonce)) throw new Error("Invalid terminal tracking nonce.");
|
|
50
|
+
// Wrap the accepted line, not the history hook (which skips duplicate commands).
|
|
51
|
+
// Leave editing/history policy intact and call the original prompt/readline before altering $?.
|
|
52
|
+
return [
|
|
53
|
+
"$global:__AgentBotPrompt = $function:prompt",
|
|
54
|
+
"$global:__AgentBotMarker = [char]27 + ']777;agentbot;" + nonce + ";'",
|
|
55
|
+
"try {",
|
|
56
|
+
" Import-Module PSReadLine -ErrorAction Stop",
|
|
57
|
+
" $global:__AgentBotReadLine = $function:PSConsoleHostReadLine",
|
|
58
|
+
" if ($null -eq $global:__AgentBotReadLine) { throw 'PSConsoleHostReadLine unavailable' }",
|
|
59
|
+
" function global:PSConsoleHostReadLine {",
|
|
60
|
+
" $line = & $global:__AgentBotReadLine",
|
|
61
|
+
" if (-not [string]::IsNullOrWhiteSpace($line)) {",
|
|
62
|
+
" $text = if ($line.Length -gt 2000) { $line.Substring(0, 2000) } else { $line }",
|
|
63
|
+
" [Console]::Write($global:__AgentBotMarker + 'command;' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($text)) + [char]7)",
|
|
64
|
+
" }",
|
|
65
|
+
" return $line",
|
|
66
|
+
" }",
|
|
67
|
+
" function global:prompt {",
|
|
68
|
+
" $text = & $global:__AgentBotPrompt",
|
|
69
|
+
" [Console]::Write($global:__AgentBotMarker + 'idle' + [char]7)",
|
|
70
|
+
" return $text",
|
|
71
|
+
" }",
|
|
72
|
+
"} catch { [Console]::Write($global:__AgentBotMarker + 'unknown' + [char]7) }",
|
|
73
|
+
].join("\n");
|
|
74
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
const record = z.object({ token: z.string().regex(/^[A-Za-z0-9_-]{8}$/u), jobId: z.string().regex(/^[a-f0-9-]{36}$/u) });
|
|
7
|
+
|
|
8
|
+
export class TerminalLinks {
|
|
9
|
+
constructor(private readonly directory: string, private readonly secret: Buffer) {
|
|
10
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
create(jobId: string): string {
|
|
14
|
+
if (!/^[a-f0-9-]{36}$/u.test(jobId)) throw new Error("Invalid terminal job ID.");
|
|
15
|
+
for (let attempt = 0; attempt < 16; attempt++) {
|
|
16
|
+
const token = createHmac("sha256", this.secret).update(`terminal:open:${jobId}:${attempt}`).digest("base64url").slice(0, 8);
|
|
17
|
+
if (this.resolve(token) === jobId) return token;
|
|
18
|
+
try {
|
|
19
|
+
fs.writeFileSync(this.filePath(token), JSON.stringify({ token, jobId }), { flag: "wx", mode: 0o600 });
|
|
20
|
+
return token;
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
23
|
+
if (this.resolve(token) === jobId) return token;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
throw new Error("Unable to allocate a terminal short link.");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
createRecovery(): string {
|
|
30
|
+
for (let attempt = 0; attempt < 16; attempt++) {
|
|
31
|
+
const token = randomBytes(6).toString("base64url");
|
|
32
|
+
try {
|
|
33
|
+
// Reserve the shared namespace permanently: an expired recovery must never open a new Shell.
|
|
34
|
+
fs.writeFileSync(this.filePath(token), JSON.stringify({ token, recovery: true }), { flag: "wx", mode: 0o600 });
|
|
35
|
+
return token;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
throw new Error("Unable to allocate a terminal recovery link.");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
resolve(token: string): string | undefined {
|
|
44
|
+
if (!/^[A-Za-z0-9_-]{8}$/u.test(token)) return undefined;
|
|
45
|
+
let content: string;
|
|
46
|
+
try { content = fs.readFileSync(this.filePath(token), "utf8"); }
|
|
47
|
+
catch (error) {
|
|
48
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
let value: unknown;
|
|
52
|
+
try { value = JSON.parse(content) as unknown; } catch { return undefined; }
|
|
53
|
+
const parsed = record.safeParse(value);
|
|
54
|
+
return parsed.success && parsed.data.token === token ? parsed.data.jobId : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private filePath(token: string): string {
|
|
58
|
+
// Hash filenames so case-sensitive URL tokens also work on Windows filesystems.
|
|
59
|
+
return path.join(this.directory, `${createHash("sha256").update(token).digest("hex")}.json`);
|
|
60
|
+
}
|
|
61
|
+
}
|