@keyou007/agent-bot 0.1.24-alpha.5 → 0.1.24-alpha.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +27 -3
- package/README.zh.md +27 -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/CodexRuntime.js +202 -115
- 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/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 +66 -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 +43 -6
- 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 +615 -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 +1 -0
- package/dist/supervision/SafeRestartNotifier.js.map +1 -1
- package/dist/supervision/SafeRestartScheduler.js +19 -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/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 +71 -0
- package/dist/terminal/TerminalProcess.js.map +1 -0
- package/dist/terminal/TerminalServer.js +639 -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 +9 -2
- package/src/cli/controlProtocol.ts +2 -1
- package/src/cli/serverStatus.ts +1 -0
- package/src/cli.ts +6 -1
- package/src/codex/CodexRuntime.ts +200 -123
- package/src/codex/ProviderSwitch.ts +11 -12
- 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 +81 -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 +44 -7
- 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 +625 -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 +1 -0
- package/src/supervision/SafeRestartScheduler.ts +18 -6
- package/src/terminal/TerminalCards.ts +18 -0
- package/src/terminal/TerminalLinks.ts +61 -0
- package/src/terminal/TerminalPage.ts +213 -0
- package/src/terminal/TerminalProcess.ts +71 -0
- package/src/terminal/TerminalServer.ts +513 -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;
|
|
@@ -132,6 +132,7 @@ export class SafeRestartNotifier {
|
|
|
132
132
|
phase: status.phase,
|
|
133
133
|
remainingMs: status.remainingMs,
|
|
134
134
|
pendingFinalDeliveries: status.activity.pendingFinalDeliveries,
|
|
135
|
+
activeTerminals: status.activity.activeTerminals,
|
|
135
136
|
waitingTasks,
|
|
136
137
|
});
|
|
137
138
|
const cardHash = JSON.stringify(card);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export interface ServerActivityState {
|
|
2
|
+
activeTerminals?: number;
|
|
2
3
|
runningSessions: number;
|
|
3
4
|
pendingFinalDeliveries: number;
|
|
4
5
|
latestInboundAt?: string;
|
|
@@ -49,6 +50,7 @@ export class SafeRestartScheduler {
|
|
|
49
50
|
private idleInboundAt?: string;
|
|
50
51
|
private scheduleId = 0;
|
|
51
52
|
private restartImmediatelyIfIdle = false;
|
|
53
|
+
private restartingNow = false;
|
|
52
54
|
|
|
53
55
|
constructor(private readonly options: SafeRestartSchedulerOptions) {}
|
|
54
56
|
|
|
@@ -56,6 +58,10 @@ export class SafeRestartScheduler {
|
|
|
56
58
|
return Boolean(this.reason);
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
get restarting(): boolean {
|
|
62
|
+
return this.restartingNow;
|
|
63
|
+
}
|
|
64
|
+
|
|
59
65
|
get pendingReason(): string | undefined {
|
|
60
66
|
return this.reason;
|
|
61
67
|
}
|
|
@@ -131,14 +137,14 @@ export class SafeRestartScheduler {
|
|
|
131
137
|
if (!reason) return;
|
|
132
138
|
const scheduleId = this.scheduleId;
|
|
133
139
|
const state = this.options.readActivity();
|
|
134
|
-
if (state
|
|
140
|
+
if (hasActiveWork(state) || state.pendingFinalDeliveries > 0) {
|
|
135
141
|
this.idleSince = undefined;
|
|
136
142
|
this.idleInboundAt = undefined;
|
|
137
143
|
await this.emitStatus({
|
|
138
144
|
scheduleId,
|
|
139
145
|
reason,
|
|
140
146
|
notificationTargets: this.notificationTargetsSnapshot(),
|
|
141
|
-
phase: state
|
|
147
|
+
phase: hasActiveWork(state) ? "waiting_tasks" : "waiting_delivery",
|
|
142
148
|
activity: state,
|
|
143
149
|
});
|
|
144
150
|
return;
|
|
@@ -176,18 +182,18 @@ export class SafeRestartScheduler {
|
|
|
176
182
|
}
|
|
177
183
|
const confirmed = this.options.readActivity();
|
|
178
184
|
if (
|
|
179
|
-
confirmed
|
|
185
|
+
hasActiveWork(confirmed)
|
|
180
186
|
|| confirmed.pendingFinalDeliveries > 0
|
|
181
187
|
|| (confirmed.latestInboundAt ?? "") !== this.idleInboundAt
|
|
182
188
|
) {
|
|
183
189
|
this.idleSince = undefined;
|
|
184
190
|
this.idleInboundAt = undefined;
|
|
185
|
-
if (confirmed
|
|
191
|
+
if (hasActiveWork(confirmed) || confirmed.pendingFinalDeliveries > 0) {
|
|
186
192
|
await this.emitStatus({
|
|
187
193
|
scheduleId,
|
|
188
194
|
reason,
|
|
189
195
|
notificationTargets: this.notificationTargetsSnapshot(),
|
|
190
|
-
phase: confirmed
|
|
196
|
+
phase: hasActiveWork(confirmed) ? "waiting_tasks" : "waiting_delivery",
|
|
191
197
|
activity: confirmed,
|
|
192
198
|
});
|
|
193
199
|
} else {
|
|
@@ -206,6 +212,7 @@ export class SafeRestartScheduler {
|
|
|
206
212
|
}
|
|
207
213
|
|
|
208
214
|
private async restart(reason: string, scheduleId: number, activity: ServerActivityState): Promise<void> {
|
|
215
|
+
this.restartingNow = true;
|
|
209
216
|
const notificationTargets = this.notificationTargetsSnapshot();
|
|
210
217
|
this.clear();
|
|
211
218
|
await this.emitStatus({
|
|
@@ -216,7 +223,8 @@ export class SafeRestartScheduler {
|
|
|
216
223
|
activity,
|
|
217
224
|
remainingMs: 0,
|
|
218
225
|
});
|
|
219
|
-
await this.options.onReady(reason, notificationTargets);
|
|
226
|
+
try { await this.options.onReady(reason, notificationTargets); }
|
|
227
|
+
finally { this.restartingNow = false; }
|
|
220
228
|
}
|
|
221
229
|
|
|
222
230
|
private requestPoll(): void {
|
|
@@ -269,6 +277,10 @@ export class SafeRestartScheduler {
|
|
|
269
277
|
}
|
|
270
278
|
}
|
|
271
279
|
|
|
280
|
+
function hasActiveWork(state: ServerActivityState): boolean {
|
|
281
|
+
return state.runningSessions > 0 || (state.activeTerminals ?? 0) > 0;
|
|
282
|
+
}
|
|
283
|
+
|
|
272
284
|
function normalizedNotificationTarget(
|
|
273
285
|
value: RestartNotificationTarget | undefined,
|
|
274
286
|
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,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
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
export function terminalPage(base: string, recovery = false): string {
|
|
2
|
+
base = base.replaceAll("&", "&").replaceAll(String.fromCharCode(34), """).replaceAll("<", "<").replaceAll(">", ">");
|
|
3
|
+
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
4
|
+
<title>AgentBot Terminal</title><link rel="stylesheet" href="${base}/assets/xterm.css"><link rel="stylesheet" href="${base}/assets/terminal.css">
|
|
5
|
+
<script defer src="${base}/assets/xterm.js"></script><script defer src="${base}/assets/fit.js"></script><script defer src="${base}/assets/terminal.js"></script></head>
|
|
6
|
+
<body data-base="${base}" data-recovery="${recovery}"><header><strong>AgentBot <span>Terminal</span></strong><div id="status" role="status">尚未连接</div><button id="connect">请求连接</button><button id="interrupt" disabled>Ctrl+C</button><button id="close" disabled>关闭终端</button></header>
|
|
7
|
+
<section id="welcome"><h1>在浏览器中使用本机终端</h1><p>命令在 AgentBot 所在电脑上执行,拥有其系统用户权限。不会自动执行或接管原卡片中的命令。</p><p>点击请求连接,然后返回命令所在的飞书会话或话题,由 AgentBot 所有者核对验证码并授权。不要批准非本人发起的请求。</p><button id="welcome-connect" type="button">请求连接</button><p id="code"></p></section><div id="terminal"></div>
|
|
8
|
+
<footer id="details">前台连接时不因空闲关闭;后台空闲 30 分钟或网页断开 3 分钟后自动关闭 Shell · 成功重连取消断开计时并重置空闲计时 · 可从原会话的终端管理卡打开或关闭终端</footer></body></html>`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const TERMINAL_CSS = `
|
|
12
|
+
#welcome-connect{padding:10px 20px;font:inherit}
|
|
13
|
+
:root{color-scheme:dark;font:14px system-ui,sans-serif;background:#10141b;color:#dbe4f1}*{box-sizing:border-box}body{margin:0;height:100dvh;display:flex;flex-direction:column}header{display:flex;gap:12px;align-items:center;padding:12px 18px;background:#191f2b;border-bottom:1px solid #303a4d;flex-wrap:wrap}header strong{font-size:17px}header strong span{font-weight:400;color:#9aacc7}#status{flex:1;color:#adbfdb;font-size:13px}button{background:#25334a;border:1px solid #415775;border-radius:7px;padding:8px 12px;color:#e5efff;cursor:pointer}button:hover{background:#304661}button:disabled{opacity:.45;cursor:default}#close{border-color:#86505a}#welcome{margin:40px auto;max-width:780px;padding:0 24px;line-height:1.8}h1{font-size:25px}#code{font-size:24px;letter-spacing:3px;color:#84c1ff}#terminal{flex:1;min-height:0;padding:12px 14px;display:none}.xterm{height:100%}footer{font-size:12px;color:#8f9eb7;padding:9px 18px;border-top:1px solid #293142}body.connected #welcome{display:none}body.connected #terminal{display:block}@media(max-width:650px){header{padding:10px;gap:8px}#status{min-width:100%}footer{padding:7px 10px}#terminal{padding:6px}}
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
export const TERMINAL_SCRIPT = String.raw`(() => {
|
|
17
|
+
const base = document.body.dataset.base;
|
|
18
|
+
const status = document.getElementById('status');
|
|
19
|
+
const connect = document.getElementById('connect');
|
|
20
|
+
const welcomeConnect = document.getElementById('welcome-connect');
|
|
21
|
+
const close = document.getElementById('close');
|
|
22
|
+
const interrupt = document.getElementById('interrupt');
|
|
23
|
+
const storageKey = 'agentbot-terminal:' + location.pathname;
|
|
24
|
+
const recovery = document.body.dataset.recovery === 'true';
|
|
25
|
+
if (recovery) document.querySelector('#welcome h1').textContent = '恢复已有终端';
|
|
26
|
+
let session, ws, term, observer, retryTimer, socketTimer, checking, visibilityTimer;
|
|
27
|
+
let socketReady = false;
|
|
28
|
+
let epoch = 0, failures = 0, stopped = false, pageHidden = false, offline = false, busy = false;
|
|
29
|
+
try { session = JSON.parse(sessionStorage.getItem(storageKey) || 'null'); } catch {}
|
|
30
|
+
const awake = () => !pageHidden && !document.hidden && !offline;
|
|
31
|
+
const post = async (route, data, controller = new AbortController()) => {
|
|
32
|
+
const timer = setTimeout(() => controller.abort(), 10000);
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetch(base + '/api/' + route, { method:'POST', credentials:'omit', cache:'no-store',
|
|
35
|
+
signal:controller.signal, headers:{'Content-Type':'application/json'}, body:JSON.stringify(data) });
|
|
36
|
+
let value;
|
|
37
|
+
try { value = await response.json(); } catch {
|
|
38
|
+
throw Object.assign(new Error('服务返回了非 JSON 响应'), {httpStatus:response.status});
|
|
39
|
+
}
|
|
40
|
+
if (!response.ok) throw Object.assign(new Error(value.error || '请求失败'), {httpStatus:response.status});
|
|
41
|
+
return value;
|
|
42
|
+
} finally { clearTimeout(timer); }
|
|
43
|
+
};
|
|
44
|
+
function message(error) {
|
|
45
|
+
if (error?.name === 'AbortError') return '请求超时';
|
|
46
|
+
if (!error?.httpStatus && (error?.name === 'TypeError' || error?.message === 'Failed to fetch')) return '网络暂时不可用';
|
|
47
|
+
return error?.message || String(error);
|
|
48
|
+
}
|
|
49
|
+
function buttons(retry = false) {
|
|
50
|
+
for (const button of [connect, welcomeConnect]) {
|
|
51
|
+
button.textContent = session ? '重新连接' : recovery ? '请求恢复连接' : '请求连接';
|
|
52
|
+
button.disabled = busy || (!!session && !retry);
|
|
53
|
+
}
|
|
54
|
+
close.disabled = busy || !session;
|
|
55
|
+
}
|
|
56
|
+
function invalidate() {
|
|
57
|
+
epoch++; clearTimeout(retryTimer); clearTimeout(socketTimer);
|
|
58
|
+
clearTimeout(visibilityTimer); socketReady = false;
|
|
59
|
+
checking?.abort(); checking = undefined;
|
|
60
|
+
const old = ws; ws = undefined; old?.close(); interrupt.disabled = true;
|
|
61
|
+
if (term) term.options.disableStdin = true;
|
|
62
|
+
}
|
|
63
|
+
function forget() {
|
|
64
|
+
session = undefined; sessionStorage.removeItem(storageKey);
|
|
65
|
+
document.getElementById('code').textContent = ''; buttons(true);
|
|
66
|
+
}
|
|
67
|
+
function end(text) { stopped = true; invalidate(); forget(); status.textContent = text; }
|
|
68
|
+
function fail(text) { stopped = true; invalidate(); status.textContent = text; buttons(true); }
|
|
69
|
+
function schedule(delay) {
|
|
70
|
+
clearTimeout(retryTimer);
|
|
71
|
+
if (!stopped && awake() && session) retryTimer = setTimeout(check, delay);
|
|
72
|
+
}
|
|
73
|
+
function retry(stage, error) {
|
|
74
|
+
if (stopped || !session) return;
|
|
75
|
+
const seconds = Math.min(10, Math.pow(2, Math.min(failures++, 4)));
|
|
76
|
+
status.textContent = stage + ':' + message(error) + ',' + seconds + ' 秒后自动重试';
|
|
77
|
+
buttons(true); schedule(seconds * 1000);
|
|
78
|
+
}
|
|
79
|
+
function sendSize(cols, rows) {
|
|
80
|
+
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({type:'resize',cols:Math.max(2,Math.min(500,cols)),rows:Math.max(2,Math.min(200,rows))}));
|
|
81
|
+
}
|
|
82
|
+
function reportVisibility() {
|
|
83
|
+
clearTimeout(visibilityTimer);
|
|
84
|
+
if (stopped || !socketReady || ws?.readyState !== WebSocket.OPEN) return;
|
|
85
|
+
const foreground = awake();
|
|
86
|
+
ws.send(JSON.stringify({type:'visibility',foreground}));
|
|
87
|
+
if (foreground) visibilityTimer = setTimeout(reportVisibility, 30000);
|
|
88
|
+
}
|
|
89
|
+
function openSocket() {
|
|
90
|
+
if (stopped || !awake() || ws || !session) return;
|
|
91
|
+
const current = session, generation = epoch;
|
|
92
|
+
const url = new URL(base + '/socket', location.origin); url.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
93
|
+
let socket;
|
|
94
|
+
try { socket = new WebSocket(url); } catch (error) { retry('WebSocket 连接失败', error); return; }
|
|
95
|
+
ws = socket; status.textContent = '授权已通过,正在连接终端…'; buttons();
|
|
96
|
+
const valid = () => ws === socket && generation === epoch && session === current && !stopped;
|
|
97
|
+
socketTimer = setTimeout(() => {
|
|
98
|
+
if (!valid()) return;
|
|
99
|
+
invalidate(); retry('WebSocket 连接超时', new Error('请检查网络是否支持 WebSocket'));
|
|
100
|
+
}, 15000);
|
|
101
|
+
socket.onopen = () => { if (valid()) socket.send(JSON.stringify({type:'auth',requestId:current.requestId,browserKey:current.browserKey})); };
|
|
102
|
+
socket.onmessage = (event) => {
|
|
103
|
+
if (!valid()) return;
|
|
104
|
+
try {
|
|
105
|
+
const result = JSON.parse(event.data);
|
|
106
|
+
if (result.type === 'ready') {
|
|
107
|
+
clearTimeout(socketTimer);
|
|
108
|
+
socketReady = true; reportVisibility();
|
|
109
|
+
document.body.classList.add('connected');
|
|
110
|
+
observer?.disconnect(); term?.dispose();
|
|
111
|
+
const terminal = new Terminal({cursorBlink:true,fontSize:14,scrollback:5000,disableStdin:result.replay === true,theme:{background:'#10141b'}});
|
|
112
|
+
term = terminal;
|
|
113
|
+
const active = () => valid() && term === terminal;
|
|
114
|
+
terminal.attachCustomKeyEventHandler((event) => {
|
|
115
|
+
if (event.altKey || event.shiftKey || event.ctrlKey === event.metaKey) return true;
|
|
116
|
+
const key = event.key.toLowerCase();
|
|
117
|
+
// Let the browser dispatch native clipboard events, including on HTTP.
|
|
118
|
+
// xterm handles the selected text and bracketed paste without sending ^C/^V.
|
|
119
|
+
return !(key === 'v' || (key === 'c' && terminal.hasSelection()));
|
|
120
|
+
});
|
|
121
|
+
const fit = new FitAddon.FitAddon(); terminal.loadAddon(fit); terminal.open(document.getElementById('terminal'));
|
|
122
|
+
terminal.onData((data) => {
|
|
123
|
+
if (active() && !terminal.options.disableStdin && socket.readyState === WebSocket.OPEN) for (let i=0; i<data.length; i+=4096) socket.send(JSON.stringify({type:'input',data:data.slice(i,i+4096)}));
|
|
124
|
+
});
|
|
125
|
+
terminal.onResize(({cols,rows}) => { if (active()) sendSize(cols,rows); });
|
|
126
|
+
observer = new ResizeObserver(() => { if (active()) fit.fit(); });
|
|
127
|
+
observer.observe(document.getElementById('terminal'));
|
|
128
|
+
fit.fit(); sendSize(terminal.cols,terminal.rows); failures = 0; interrupt.disabled = true;
|
|
129
|
+
status.textContent = '正在恢复终端画面…';
|
|
130
|
+
const restored = () => {
|
|
131
|
+
if (!active()) return;
|
|
132
|
+
terminal.options.disableStdin = false; terminal.focus();
|
|
133
|
+
status.textContent = '已连接 · ' + result.cwd; interrupt.disabled = false; buttons();
|
|
134
|
+
};
|
|
135
|
+
// xterm parses asynchronously. Keep replay silent until its write callback,
|
|
136
|
+
// before subsequently queued live output is parsed. Startup output is live.
|
|
137
|
+
if (result.output) terminal.write(result.output, restored); else restored();
|
|
138
|
+
} else if (result.type === 'data') term?.write(result.data);
|
|
139
|
+
else if (result.type === 'exit') end(result.message || '终端已结束');
|
|
140
|
+
else if (result.type === 'error') fail((result.stage === 'spawn' ? 'Shell 启动失败:' : '终端连接被拒绝:') + result.message);
|
|
141
|
+
} catch (error) { fail('终端界面初始化或消息处理失败:' + message(error)); }
|
|
142
|
+
};
|
|
143
|
+
socket.onclose = (event) => {
|
|
144
|
+
if (!valid()) return;
|
|
145
|
+
if (event.reason === 'Reconnected from another page') { fail('该终端已在另一页面连接,点击重新连接可恢复。'); return; }
|
|
146
|
+
invalidate(); retry('WebSocket 已断开', new Error('连接关闭' + (event.code ? '(' + event.code + ')' : '')));
|
|
147
|
+
};
|
|
148
|
+
socket.onerror = () => {
|
|
149
|
+
if (!valid()) return;
|
|
150
|
+
invalidate(); retry('WebSocket 连接失败', new Error('请检查网络是否支持 WebSocket'));
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
async function check() {
|
|
154
|
+
if (stopped || !awake() || !session || checking || ws || busy) return;
|
|
155
|
+
clearTimeout(retryTimer);
|
|
156
|
+
const current = session, generation = epoch, controller = new AbortController(); checking = controller;
|
|
157
|
+
try {
|
|
158
|
+
const result = await post('status', current, controller);
|
|
159
|
+
if (generation !== epoch || current !== session || stopped || !awake()) return;
|
|
160
|
+
if (result.state === 'approved' || result.state === 'running') openSocket();
|
|
161
|
+
else if (result.state === 'pending') { failures = 0; status.textContent = '等待原会话中的所有者授权'; buttons(); schedule(1500); }
|
|
162
|
+
else end(result.message || '授权已失效,请重新请求连接');
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (generation !== epoch || current !== session || stopped || !awake()) return;
|
|
165
|
+
if ([400,401,404,410].includes(error.httpStatus)) end('授权状态查询失败:' + message(error) + '。请重新请求连接。');
|
|
166
|
+
else if (error.httpStatus === 403) fail('授权状态查询被拒绝:' + message(error));
|
|
167
|
+
else retry('暂时无法查询授权状态', error);
|
|
168
|
+
} finally { if (checking === controller) checking = undefined; }
|
|
169
|
+
}
|
|
170
|
+
connect.onclick = welcomeConnect.onclick = async () => {
|
|
171
|
+
if (busy) return;
|
|
172
|
+
stopped = false; failures = 0; invalidate();
|
|
173
|
+
if (session) { status.textContent = '正在恢复已有终端授权…'; buttons(); check(); return; }
|
|
174
|
+
busy = true; buttons(); status.textContent = '正在请求飞书授权…';
|
|
175
|
+
try {
|
|
176
|
+
document.body.classList.remove('connected');
|
|
177
|
+
if (!globalThis.crypto?.getRandomValues) throw new Error('浏览器不支持安全随机数,请使用新版浏览器。');
|
|
178
|
+
const browserKey = Array.from(crypto.getRandomValues(new Uint8Array(32)), (n) => n.toString(16).padStart(2,'0')).join('');
|
|
179
|
+
const result = await post('request', {launch:location.pathname,browserKey});
|
|
180
|
+
session = {requestId:result.requestId,browserKey,code:result.code}; sessionStorage.setItem(storageKey,JSON.stringify(session));
|
|
181
|
+
document.getElementById('code').textContent = '验证码:' + result.code;
|
|
182
|
+
status.textContent = awake() ? '等待原会话中的所有者授权' : '返回网页后将自动查询授权状态';
|
|
183
|
+
} catch (error) { status.textContent = '请求授权失败:' + message(error); }
|
|
184
|
+
finally { busy = false; buttons(!session); }
|
|
185
|
+
check();
|
|
186
|
+
};
|
|
187
|
+
interrupt.onclick = () => { if (ws?.readyState === WebSocket.OPEN && !interrupt.disabled) ws.send(JSON.stringify({type:'input',data:String.fromCharCode(3)})); };
|
|
188
|
+
close.onclick = async () => {
|
|
189
|
+
if (busy || !session || !confirm('关闭该终端和 Shell?已脱离终端的后台进程可能继续运行。')) return;
|
|
190
|
+
busy = true; stopped = true; invalidate(); buttons();
|
|
191
|
+
try { await post('close',session); end('终端已关闭'); }
|
|
192
|
+
catch (error) {
|
|
193
|
+
if ([400,401,404,410].includes(error.httpStatus)) end('终端已失效或结束');
|
|
194
|
+
else status.textContent = '关闭请求失败:' + message(error) + ';未确认关闭,请重试';
|
|
195
|
+
} finally { busy = false; buttons(true); }
|
|
196
|
+
};
|
|
197
|
+
function suspend() {
|
|
198
|
+
invalidate();
|
|
199
|
+
if (session && !stopped) { status.textContent = '连接已暂停,返回网页或网络恢复后自动重连'; buttons(true); }
|
|
200
|
+
}
|
|
201
|
+
function resume() {
|
|
202
|
+
if (stopped || !awake() || busy || !session) return;
|
|
203
|
+
clearTimeout(retryTimer); check();
|
|
204
|
+
}
|
|
205
|
+
window.addEventListener('pagehide', () => { pageHidden = true; suspend(); });
|
|
206
|
+
window.addEventListener('pageshow', () => { pageHidden = false; resume(); });
|
|
207
|
+
document.addEventListener('visibilitychange', () => { reportVisibility(); if (document.hidden) { if (!ws) suspend(); } else resume(); });
|
|
208
|
+
window.addEventListener('offline', () => { offline = true; suspend(); });
|
|
209
|
+
window.addEventListener('online', () => { offline = false; resume(); });
|
|
210
|
+
window.addEventListener('focus', resume);
|
|
211
|
+
if (session?.code) document.getElementById('code').textContent = '验证码:' + session.code;
|
|
212
|
+
buttons(); check();
|
|
213
|
+
})();`;
|