kcg-code 0.0.0-stage → 0.1.0
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 +228 -2
- package/bin/kcgcode.ts +13 -0
- package/bun-env.d.ts +19 -0
- package/kcg-code.config.example.json +3 -0
- package/package.json +83 -4
- package/public/logo.svg +1 -0
- package/public/manifest.json +18 -0
- package/public/sw.js +49 -0
- package/src/App.tsx +39 -0
- package/src/__tests__/manifest.test.ts +61 -0
- package/src/build-config.ts +9 -0
- package/src/cli/__tests__/dashboard.test.ts +62 -0
- package/src/cli/args.ts +123 -0
- package/src/cli/commands.ts +174 -0
- package/src/cli/dashboard.tsx +343 -0
- package/src/cli/index.ts +37 -0
- package/src/cli/theme.ts +100 -0
- package/src/components/auth/AuthGate.tsx +182 -0
- package/src/components/auth/AvatarEditor.tsx +236 -0
- package/src/components/auth/LockScreen.tsx +714 -0
- package/src/components/auth/ProfileAvatar.tsx +60 -0
- package/src/components/auth/WelcomeScreen.tsx +672 -0
- package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
- package/src/components/auth/avatar-icons.tsx +114 -0
- package/src/components/layout/AppShell.tsx +142 -0
- package/src/components/layout/AppSidebar.tsx +419 -0
- package/src/components/layout/SearchDialog.tsx +189 -0
- package/src/components/layout/SidebarAccount.tsx +111 -0
- package/src/components/layout/SidebarSessionMenu.tsx +163 -0
- package/src/components/layout/ThemeToggle.tsx +67 -0
- package/src/components/projects/FolderBrowser.tsx +142 -0
- package/src/components/projects/HomeChat.tsx +250 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/PanelSection.tsx +102 -0
- package/src/components/projects/ProjectInstructions.tsx +229 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +448 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +123 -0
- package/src/components/sessions/AssistantTurn.tsx +145 -0
- package/src/components/sessions/AttachmentTile.tsx +161 -0
- package/src/components/sessions/CodeBlock.tsx +107 -0
- package/src/components/sessions/ComposerActionSheet.tsx +96 -0
- package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +72 -0
- package/src/components/sessions/ContextPanel.tsx +561 -0
- package/src/components/sessions/DropPlate.tsx +165 -0
- package/src/components/sessions/FileDropOverlay.tsx +232 -0
- package/src/components/sessions/FileMentionDropdown.tsx +47 -0
- package/src/components/sessions/ImageLightbox.tsx +173 -0
- package/src/components/sessions/MarkdownContent.tsx +187 -0
- package/src/components/sessions/Mascot.tsx +47 -0
- package/src/components/sessions/MessageActions.tsx +60 -0
- package/src/components/sessions/MessageRail.tsx +121 -0
- package/src/components/sessions/ModelPicker.tsx +209 -0
- package/src/components/sessions/ModelSearchList.tsx +151 -0
- package/src/components/sessions/NewSessionDialog.tsx +189 -0
- package/src/components/sessions/PendingAttachments.tsx +127 -0
- package/src/components/sessions/PromptCard.tsx +1085 -0
- package/src/components/sessions/PromptPanel.tsx +112 -0
- package/src/components/sessions/SessionComposer.tsx +640 -0
- package/src/components/sessions/SessionHeader.tsx +175 -0
- package/src/components/sessions/SessionList.tsx +396 -0
- package/src/components/sessions/SessionRow.tsx +137 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +236 -0
- package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
- package/src/components/sessions/UserMessage.tsx +177 -0
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +220 -0
- package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
- package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
- package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
- package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
- package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
- package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
- package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
- package/src/components/settings/AboutGroup.tsx +170 -0
- package/src/components/settings/AccountGroup.tsx +122 -0
- package/src/components/settings/DevicesGroup.tsx +151 -0
- package/src/components/settings/LhrRemoteAccess.tsx +262 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1226 -0
- package/src/components/settings/SecurityGroup.tsx +566 -0
- package/src/components/settings/WorkspaceGroup.tsx +370 -0
- package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
- package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
- package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
- package/src/components/settings/prefs.tsx +265 -0
- package/src/components/skills/InstallNotifications.tsx +325 -0
- package/src/components/skills/InstallSkillDialog.tsx +233 -0
- package/src/components/skills/SkillCards.tsx +210 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -0
- package/src/components/ui/alert-dialog.tsx +179 -0
- package/src/components/ui/alert.tsx +60 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/bubble.tsx +125 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +75 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/dialog.tsx +142 -0
- package/src/components/ui/dropdown-menu.tsx +225 -0
- package/src/components/ui/empty.tsx +94 -0
- package/src/components/ui/field.tsx +234 -0
- package/src/components/ui/input-group.tsx +161 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/message-scroller.tsx +131 -0
- package/src/components/ui/message.tsx +85 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/select.tsx +172 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +699 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/config.ts +85 -0
- package/src/db.ts +146 -0
- package/src/frontend.tsx +51 -0
- package/src/hooks/__tests__/useMention.test.ts +79 -0
- package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
- package/src/hooks/__tests__/useRouter.test.ts +17 -0
- package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
- package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
- package/src/hooks/__tests__/useTheme.test.ts +118 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/hooks/useAgentPicker.ts +97 -0
- package/src/hooks/useMediaQuery.ts +49 -0
- package/src/hooks/useMention.ts +236 -0
- package/src/hooks/useProjects.ts +88 -0
- package/src/hooks/useResyncOnReconnect.ts +28 -0
- package/src/hooks/useRouter.ts +92 -0
- package/src/hooks/useSessionChat.ts +427 -0
- package/src/hooks/useSessionContext.ts +106 -0
- package/src/hooks/useSessionList.ts +234 -0
- package/src/hooks/useSidebarData.ts +108 -0
- package/src/hooks/useSmoothText.ts +122 -0
- package/src/hooks/useTheme.ts +164 -0
- package/src/hooks/useWebSocket.ts +196 -0
- package/src/index.css +27 -0
- package/src/index.html +14 -0
- package/src/index.ts +49 -0
- package/src/lib/__tests__/attachments.test.ts +58 -0
- package/src/lib/__tests__/avatar-presets.test.ts +25 -0
- package/src/lib/__tests__/collapsible.test.ts +101 -0
- package/src/lib/__tests__/composer.test.ts +118 -0
- package/src/lib/__tests__/highlight.test.ts +42 -0
- package/src/lib/__tests__/home-chat.test.ts +64 -0
- package/src/lib/__tests__/lock-screen.test.ts +64 -0
- package/src/lib/__tests__/model-picker.test.ts +205 -0
- package/src/lib/__tests__/onboarding.test.ts +41 -0
- package/src/lib/__tests__/permission-info.test.ts +61 -0
- package/src/lib/__tests__/project-extensions.test.ts +52 -0
- package/src/lib/__tests__/project-overview.test.ts +173 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -0
- package/src/lib/__tests__/session-summary.test.ts +115 -0
- package/src/lib/__tests__/session-title.test.ts +58 -0
- package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
- package/src/lib/__tests__/skill-installs.test.ts +134 -0
- package/src/lib/__tests__/skills-catalog.test.ts +96 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -0
- package/src/lib/api.ts +306 -0
- package/src/lib/attachments.ts +203 -0
- package/src/lib/auth.ts +92 -0
- package/src/lib/avatar-presets.ts +31 -0
- package/src/lib/collapsible.ts +75 -0
- package/src/lib/composer.ts +89 -0
- package/src/lib/data-events.ts +17 -0
- package/src/lib/headlines.ts +30 -0
- package/src/lib/highlight.ts +186 -0
- package/src/lib/layout.ts +20 -0
- package/src/lib/lock-screen.ts +54 -0
- package/src/lib/model-picker.ts +133 -0
- package/src/lib/onboarding.ts +38 -0
- package/src/lib/passkeys.ts +113 -0
- package/src/lib/pending-prompt.ts +53 -0
- package/src/lib/permission-info.ts +101 -0
- package/src/lib/project-extensions.ts +74 -0
- package/src/lib/project-overview.ts +117 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +115 -0
- package/src/lib/session-handoff.ts +25 -0
- package/src/lib/session-status.ts +45 -0
- package/src/lib/session-summary.ts +68 -0
- package/src/lib/session-title.ts +63 -0
- package/src/lib/sidebar-groups.ts +78 -0
- package/src/lib/skill-installs.ts +360 -0
- package/src/lib/skills-catalog.ts +99 -0
- package/src/lib/time.ts +22 -0
- package/src/lib/tone.ts +80 -0
- package/src/lib/turns.ts +320 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +31 -0
- package/src/pages/projects/ProjectDetailPage.tsx +130 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +299 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +9 -0
- package/src/pages/skills/SkillsPage.tsx +701 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1031 -0
- package/src/server/__tests__/auth.test.ts +370 -0
- package/src/server/__tests__/config.test.ts +101 -0
- package/src/server/__tests__/db.test.ts +485 -0
- package/src/server/__tests__/host.test.ts +14 -0
- package/src/server/__tests__/tunnel-config.test.ts +38 -0
- package/src/server/app.ts +415 -0
- package/src/server/db/auth.ts +311 -0
- package/src/server/db/messages.ts +50 -0
- package/src/server/db/projects.ts +106 -0
- package/src/server/db/prompts.ts +78 -0
- package/src/server/db/rows.ts +150 -0
- package/src/server/db/schema.ts +221 -0
- package/src/server/db/sessions.ts +239 -0
- package/src/server/db/tunnel.ts +124 -0
- package/src/server/host.ts +12 -0
- package/src/server/result.ts +15 -0
- package/src/server/routes/auth-guard.ts +156 -0
- package/src/server/routes/auth.routes.ts +287 -0
- package/src/server/routes/helpers.ts +165 -0
- package/src/server/routes/meta.routes.ts +24 -0
- package/src/server/routes/passkeys.routes.ts +161 -0
- package/src/server/routes/projects.routes.ts +157 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +117 -0
- package/src/server/routes/tunnel.routes.ts +132 -0
- package/src/server/routes/types.ts +35 -0
- package/src/server/routes/uploads.routes.ts +54 -0
- package/src/server/routes/workspace.routes.ts +127 -0
- package/src/server/services/__tests__/app-info.test.ts +18 -0
- package/src/server/services/__tests__/attachments.test.ts +159 -0
- package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
- package/src/server/services/__tests__/auth.test.ts +341 -0
- package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
- package/src/server/services/__tests__/git-info.test.ts +74 -0
- package/src/server/services/__tests__/opencode.test.ts +320 -0
- package/src/server/services/__tests__/project-manager.test.ts +190 -0
- package/src/server/services/__tests__/sandbox.test.ts +141 -0
- package/src/server/services/__tests__/session-manager.test.ts +3469 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +226 -0
- package/src/server/services/__tests__/skills-registry.test.ts +359 -0
- package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
- package/src/server/services/__tests__/tunnel.test.ts +367 -0
- package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
- package/src/server/services/__tests__/workspace.test.ts +249 -0
- package/src/server/services/app-info.ts +29 -0
- package/src/server/services/attachments.ts +193 -0
- package/src/server/services/auth-passkeys.ts +459 -0
- package/src/server/services/auth.ts +633 -0
- package/src/server/services/frpc-installer.ts +183 -0
- package/src/server/services/git-info.ts +87 -0
- package/src/server/services/opencode-client.ts +714 -0
- package/src/server/services/opencode-server.ts +319 -0
- package/src/server/services/opencode-sse.ts +82 -0
- package/src/server/services/project-manager.ts +147 -0
- package/src/server/services/sandbox.ts +83 -0
- package/src/server/services/session-events.ts +256 -0
- package/src/server/services/session-manager.ts +1374 -0
- package/src/server/services/skill-install-jobs.ts +262 -0
- package/src/server/services/skills-registry.ts +468 -0
- package/src/server/services/tunnel-api.ts +207 -0
- package/src/server/services/tunnel-config.ts +45 -0
- package/src/server/services/tunnel-lhr.ts +387 -0
- package/src/server/services/tunnel.ts +633 -0
- package/src/server/services/turn-stream.ts +339 -0
- package/src/server/services/websocket-gateway.ts +374 -0
- package/src/server/services/workspace.ts +223 -0
- package/src/server/services/ws-transport.ts +121 -0
- package/src/types/context.ts +69 -0
- package/src/types/index.ts +13 -0
- package/src/types/message.ts +58 -0
- package/src/types/project.ts +16 -0
- package/src/types/prompt.ts +118 -0
- package/src/types/session.ts +52 -0
- package/src/types/turns.ts +47 -0
- package/src/ws-protocol.ts +93 -0
- package/styles/globals.css +311 -0
- package/tsconfig.json +37 -0
|
@@ -0,0 +1,1374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session_Manager — lifecycle Session & orkestrasi headless opencode.
|
|
3
|
+
*
|
|
4
|
+
* Menggantikan versi PTY/TUI:
|
|
5
|
+
* - `createSession` memastikan server `opencode serve` untuk Project lalu
|
|
6
|
+
* membuat Session di server headless (`POST /session`) — bukan spawn PTY.
|
|
7
|
+
* - Output bukan chunk terminal, melainkan `SessionMessage` terstruktur yang
|
|
8
|
+
* disimpan di `messages` dan diteruskan via hook `onMessage`.
|
|
9
|
+
* - Prompt dikirim lewat `prompt_async` (204) dan turn ditutup oleh event
|
|
10
|
+
* `session.idle` Session akar; pesan assistant dirakit dari parts SSE.
|
|
11
|
+
* Sebelumnya balasan `POST /message` yang menggantung dipakai, sehingga turn
|
|
12
|
+
* panjang (sub-agent) gagal tersimpan saat koneksi HTTP-nya putus.
|
|
13
|
+
* - Interactive_Prompt berasal dari event SSE terstruktur:
|
|
14
|
+
* `permission.asked` (-> confirmation) dan `question.asked` (-> menu) —
|
|
15
|
+
* `prompt-detector.ts` (regex atas teks TUI) dihapus. Varian `*.v2.asked`
|
|
16
|
+
* ikut ditangani (skema opencode mengekspos keduanya) dengan penamaan field
|
|
17
|
+
* berbeda: v2 memakai `action`/`resources`, v1 `permission`/`patterns`.
|
|
18
|
+
* - Sub-agent (`task`) dijalankan opencode sebagai Session terpisah dengan
|
|
19
|
+
* `parentID`; `session.created` memetakan child tersebut ke Session lokal
|
|
20
|
+
* induk agar prompt izin & streaming-nya tidak terbuang.
|
|
21
|
+
* - `resolvePrompt` menjawab lewat `POST /permission/{id}/reply` /
|
|
22
|
+
* `POST /question/{id}/reply|reject` — bukan menulis `y\n` ke PTY.
|
|
23
|
+
*
|
|
24
|
+
* Prinsip error tetap sama: fungsi publik mengembalikan
|
|
25
|
+
* `{ ok: true, data } | { ok: false, error }`, dan Session berstatus
|
|
26
|
+
* `running` hanya setelah berhasil dibuat di server headless.
|
|
27
|
+
*/
|
|
28
|
+
import { randomUUID } from "node:crypto";
|
|
29
|
+
import { existsSync, statSync } from "node:fs";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import type { SessionStore } from "../../db";
|
|
32
|
+
import { modelFacingMime } from "../../lib/attachments";
|
|
33
|
+
import type {
|
|
34
|
+
AgentType,
|
|
35
|
+
InteractivePrompt,
|
|
36
|
+
MessagePart,
|
|
37
|
+
PromptResponse,
|
|
38
|
+
Session,
|
|
39
|
+
SessionContext,
|
|
40
|
+
SessionMessage,
|
|
41
|
+
SessionModel,
|
|
42
|
+
SessionStatus,
|
|
43
|
+
} from "../../types";
|
|
44
|
+
import type { Result, SimpleResult } from "../result";
|
|
45
|
+
import type { AttachmentManager } from "./attachments";
|
|
46
|
+
import { EMPTY_GIT_INFO, readGitInfo } from "./git-info";
|
|
47
|
+
import type {
|
|
48
|
+
AgentOption,
|
|
49
|
+
McpServerInfo,
|
|
50
|
+
ModelOption,
|
|
51
|
+
OpenCodeClient,
|
|
52
|
+
OpenCodeEvent,
|
|
53
|
+
OpenCodeFileRef,
|
|
54
|
+
SkillInfo,
|
|
55
|
+
} from "./opencode-client";
|
|
56
|
+
import { OC_REQUEST_GONE } from "./opencode-client";
|
|
57
|
+
import type { OpenCodeServerManager } from "./opencode-server";
|
|
58
|
+
import {
|
|
59
|
+
deriveSessionTitle,
|
|
60
|
+
describeSessionError,
|
|
61
|
+
eventField as field,
|
|
62
|
+
friendlySendError,
|
|
63
|
+
isPlaceholderTitle,
|
|
64
|
+
permissionGroupKey,
|
|
65
|
+
promptFromPermission,
|
|
66
|
+
promptFromQuestion,
|
|
67
|
+
retryStatusMessage,
|
|
68
|
+
} from "./session-events";
|
|
69
|
+
import { TurnStream } from "./turn-stream";
|
|
70
|
+
|
|
71
|
+
export const MAX_FREE_TEXT_LENGTH = 10000;
|
|
72
|
+
/**
|
|
73
|
+
* Batas IDLE turn (jaring pengaman bila `session.idle` tak kunjung datang).
|
|
74
|
+
* Bukan batas total: timer di-reset tiap ada tanda hidup opencode (part
|
|
75
|
+
* streaming, pesan assistant, status busy/retry) — turn panjang yang sehat
|
|
76
|
+
* (reasoning panjang, tool lambat, sub-agent, retry provider) tidak dipotong;
|
|
77
|
+
* hanya keheningan TOTAL selama durasi ini yang memicu fail.
|
|
78
|
+
*/
|
|
79
|
+
export const TURN_IDLE_TIMEOUT_MS = 300_000;
|
|
80
|
+
export const SHUTDOWN_BUDGET_MS = 5000;
|
|
81
|
+
|
|
82
|
+
/** v1 headless: hanya opencode (claude-code punya mekanisme headless sendiri). */
|
|
83
|
+
export const SUPPORTED_AGENT_TYPES: readonly AgentType[] = ["opencode"];
|
|
84
|
+
|
|
85
|
+
export interface CreateSessionRequest {
|
|
86
|
+
agentType: AgentType;
|
|
87
|
+
projectId: string;
|
|
88
|
+
/** Model pilihan user; null/undefined = model default opencode. */
|
|
89
|
+
model?: SessionModel | null;
|
|
90
|
+
/** Agent (mode) pilihan user; null/undefined = default opencode. */
|
|
91
|
+
agent?: string | null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type CreateSessionResult = { ok: true; session: Session } | { ok: false; error: string };
|
|
95
|
+
|
|
96
|
+
export type { SimpleResult };
|
|
97
|
+
|
|
98
|
+
/** Default jeda refresh skill tertunda setelah turn terakhir selesai. */
|
|
99
|
+
const SKILL_REFRESH_DELAY_MS = 1500;
|
|
100
|
+
|
|
101
|
+
export interface SessionManagerOptions {
|
|
102
|
+
store: SessionStore;
|
|
103
|
+
servers: OpenCodeServerManager;
|
|
104
|
+
/**
|
|
105
|
+
* Penyimpanan lampiran gambar (upload dari perangkat). Bila tidak diisi,
|
|
106
|
+
* fitur gambar dinonaktifkan — pesan dengan `images` ditolak.
|
|
107
|
+
*/
|
|
108
|
+
attachments?: AttachmentManager;
|
|
109
|
+
now?: () => number;
|
|
110
|
+
shutdownBudgetMs?: number;
|
|
111
|
+
/**
|
|
112
|
+
* Batas idle turn (ms) — jaring pengaman bila `session.idle` tak kunjung
|
|
113
|
+
* datang. Di-reset tiap ada tanda hidup; bukan batas total turn.
|
|
114
|
+
*/
|
|
115
|
+
turnIdleTimeoutMs?: number;
|
|
116
|
+
setTimeoutFn?: (cb: () => void, ms: number) => unknown;
|
|
117
|
+
clearTimeoutFn?: (handle: unknown) => void;
|
|
118
|
+
/** Hook pesan baru — disambungkan ke WebSocket_Gateway. */
|
|
119
|
+
onMessage?: (message: SessionMessage) => void;
|
|
120
|
+
/**
|
|
121
|
+
* Hook part pesan yang sedang di-stream (SSE `message.part.updated`).
|
|
122
|
+
* `messageId` = id pesan assistant di opencode yang sedang dibangun.
|
|
123
|
+
*/
|
|
124
|
+
onMessagePart?: (sessionId: string, messageId: string, part: MessagePart) => void;
|
|
125
|
+
/**
|
|
126
|
+
* Hook potongan teks streaming (SSE `message.part.delta`) — token model
|
|
127
|
+
* tiba lewat event ini; lihat `TurnStream.acceptDelta`.
|
|
128
|
+
*/
|
|
129
|
+
onMessagePartDelta?: (
|
|
130
|
+
sessionId: string,
|
|
131
|
+
messageId: string,
|
|
132
|
+
partId: string,
|
|
133
|
+
field: string,
|
|
134
|
+
delta: string,
|
|
135
|
+
) => void;
|
|
136
|
+
/** Hook Interactive_Prompt baru — disambungkan ke WebSocket_Gateway. */
|
|
137
|
+
onPrompt?: (prompt: InteractivePrompt) => void;
|
|
138
|
+
/**
|
|
139
|
+
* Hook prompt kembar yang ikut ter-resolve lewat fan-out jawaban kartu
|
|
140
|
+
* grup (lihat `permissionGroupKey`) — agar kartu kembar hilang dari UI
|
|
141
|
+
* client lain tanpa menunggu reattach.
|
|
142
|
+
*/
|
|
143
|
+
onPromptResolved?: (sessionId: string, promptId: string) => void;
|
|
144
|
+
/** Hook perubahan status Session — disambungkan ke WebSocket_Gateway. */
|
|
145
|
+
onStatusChange?: (sessionId: string, status: SessionStatus) => void;
|
|
146
|
+
/**
|
|
147
|
+
* Hook judul Session baru hasil generate opencode (SSE `session.updated`
|
|
148
|
+
* setelah prompt pertama) — disambungkan ke WebSocket_Gateway agar daftar
|
|
149
|
+
* Session di Client ikut terbarui tanpa reload.
|
|
150
|
+
*/
|
|
151
|
+
onTitleChange?: (sessionId: string, title: string) => void;
|
|
152
|
+
/** Hook Session dihapus permanen — disambungkan ke WebSocket_Gateway. */
|
|
153
|
+
onDeleted?: (sessionId: string) => void;
|
|
154
|
+
/** Hook error asinkron (mis. balasan model gagal) — disambungkan ke gateway. */
|
|
155
|
+
onError?: (sessionId: string, message: string) => void;
|
|
156
|
+
/**
|
|
157
|
+
* Hook perubahan status turn — `true` saat model mulai merespon (turn
|
|
158
|
+
* streaming aktif), `false` saat turn selesai/dibatalkan. Disambungkan ke
|
|
159
|
+
* gateway agar Client tahu kapan tombol stop (interrupt) perlu tampil.
|
|
160
|
+
*/
|
|
161
|
+
onTurnChange?: (sessionId: string, active: boolean) => void;
|
|
162
|
+
/**
|
|
163
|
+
* Refresh skill yang sempat tertunda (turn aktif saat skill dipasang) baru
|
|
164
|
+
* saja dijalankan otomatis untuk Project ini.
|
|
165
|
+
*/
|
|
166
|
+
onSkillsRefreshed?: (projectId: string) => void;
|
|
167
|
+
/** Jeda refresh skill tertunda setelah turn terakhir selesai (ms). */
|
|
168
|
+
skillRefreshDelayMs?: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface SessionManager {
|
|
172
|
+
createSession(req: CreateSessionRequest): Promise<CreateSessionResult>;
|
|
173
|
+
listSessions(): Session[];
|
|
174
|
+
getSession(sessionId: string): Result<Session>;
|
|
175
|
+
stopSession(sessionId: string): SimpleResult;
|
|
176
|
+
/**
|
|
177
|
+
* Hentikan balasan model yang sedang berlangsung (interrupt ala opencode):
|
|
178
|
+
* turn remote di-abort, parts yang sudah ter-stream disimpan, tapi Session
|
|
179
|
+
* TETAP `running` — user bisa langsung kirim pesan baru tanpa Start ulang.
|
|
180
|
+
*/
|
|
181
|
+
interruptSession(sessionId: string): SimpleResult;
|
|
182
|
+
/**
|
|
183
|
+
* Hapus Session permanen: pastikan server headless opencode hidup (spawn
|
|
184
|
+
* ulang bila perlu) lalu hapus session remote di sana (beserta riwayat
|
|
185
|
+
* pesannya), kemudian baris Session di Session_Store.
|
|
186
|
+
*/
|
|
187
|
+
deleteSession(sessionId: string): Promise<SimpleResult>;
|
|
188
|
+
/**
|
|
189
|
+
* Menghidupkan kembali Session yang `stopped`/`crashed` — memakai ocSessionId
|
|
190
|
+
* lama bila masih dikenal server headless, memakai sesi remote baru bila
|
|
191
|
+
* tidak, lalu status kembali `running`.
|
|
192
|
+
*/
|
|
193
|
+
resumeSession(sessionId: string): Promise<SimpleResult>;
|
|
194
|
+
/**
|
|
195
|
+
* Lepaskan seluruh sumber daya milik satu Project sebelum Project dihapus:
|
|
196
|
+
* hapus tiap Session-nya (termasuk sesi remote opencode & lampiran) lalu
|
|
197
|
+
* hentikan server headless Project. Gagal pada salah satu Session ->
|
|
198
|
+
* error diteruskan tanpa melanjutkan, agar tidak ada sesi yatim.
|
|
199
|
+
*/
|
|
200
|
+
releaseProject(projectId: string): Promise<SimpleResult>;
|
|
201
|
+
/** Daftar model yang tersedia pada server headless milik Project. */
|
|
202
|
+
listModels(projectId: string): Promise<Result<ModelOption[]>>;
|
|
203
|
+
/**
|
|
204
|
+
* Daftar agent (mode) opencode milik Project — primary/all saja, siap
|
|
205
|
+
* ditampilkan di pemilih mode composer.
|
|
206
|
+
*/
|
|
207
|
+
listAgents(projectId: string): Promise<Result<AgentOption[]>>;
|
|
208
|
+
/** Status MCP server yang terkonfigurasi untuk Project. */
|
|
209
|
+
listMcp(projectId: string): Promise<Result<McpServerInfo[]>>;
|
|
210
|
+
/** Skill yang terdaftar untuk Project (global, folder Project, bawaan). */
|
|
211
|
+
listSkills(projectId: string): Promise<Result<SkillInfo[]>>;
|
|
212
|
+
/**
|
|
213
|
+
* Muat ulang daftar skill server headless Project setelah skill dipasang.
|
|
214
|
+
* Bila ada turn aktif di Project, refresh ditunda (`refreshed: false`) agar
|
|
215
|
+
* chat yang berjalan tidak terganggu — lalu dijalankan OTOMATIS begitu
|
|
216
|
+
* seluruh turn di Project selesai (`onSkillsRefreshed`). Server yang belum
|
|
217
|
+
* hidup tidak perlu di-refresh.
|
|
218
|
+
*/
|
|
219
|
+
refreshSkills(projectId: string): Promise<Result<{ refreshed: boolean }>>;
|
|
220
|
+
/** Project dengan refresh skill tertunda (menunggu turn selesai). */
|
|
221
|
+
hasPendingSkillRefresh(projectId: string): boolean;
|
|
222
|
+
/** Cari file project untuk autocomplete `@file` di composer. */
|
|
223
|
+
findFiles(projectId: string, query: string): Promise<Result<string[]>>;
|
|
224
|
+
/**
|
|
225
|
+
* Konteks kerja Session untuk panel kanan: perubahan file di worktree,
|
|
226
|
+
* cabang git, dan task list (`todowrite`) agent.
|
|
227
|
+
*/
|
|
228
|
+
sessionContext(sessionId: string): Promise<Result<SessionContext>>;
|
|
229
|
+
/** Ganti model pilihan Session (`null` = kembali ke default opencode). */
|
|
230
|
+
setSessionModel(sessionId: string, model: SessionModel | null): SimpleResult;
|
|
231
|
+
/**
|
|
232
|
+
* Ganti agent (mode) pilihan Session — `build`, `plan`, atau agent kustom
|
|
233
|
+
* (`null` = default opencode). Berlaku pada prompt berikutnya.
|
|
234
|
+
*/
|
|
235
|
+
setSessionAgent(sessionId: string, agent: string | null): SimpleResult;
|
|
236
|
+
/**
|
|
237
|
+
* Kirim input bebas. `files` = path relatif project (`@file`); `images` =
|
|
238
|
+
* id lampiran di Attachment_Store (gambar upload dari perangkat).
|
|
239
|
+
*/
|
|
240
|
+
sendFreeTextInput(
|
|
241
|
+
sessionId: string,
|
|
242
|
+
text: string,
|
|
243
|
+
files?: string[],
|
|
244
|
+
images?: string[],
|
|
245
|
+
): Promise<SimpleResult>;
|
|
246
|
+
resolvePrompt(
|
|
247
|
+
sessionId: string,
|
|
248
|
+
promptId: string,
|
|
249
|
+
response: PromptResponse,
|
|
250
|
+
): Promise<SimpleResult>;
|
|
251
|
+
reconcileOnStartup(): void;
|
|
252
|
+
shutdown(): Promise<void>;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Membuat instance Session_Manager terikat pada `store` dan `servers`.
|
|
257
|
+
* `servers` (OpenCode_Server), `now`, dan timer dapat diinjeksi untuk test.
|
|
258
|
+
*/
|
|
259
|
+
export function createSessionManager(opts: SessionManagerOptions): SessionManager {
|
|
260
|
+
const store = opts.store;
|
|
261
|
+
const servers = opts.servers;
|
|
262
|
+
const attachments = opts.attachments;
|
|
263
|
+
const now = opts.now ?? Date.now;
|
|
264
|
+
const shutdownBudgetMs = opts.shutdownBudgetMs ?? SHUTDOWN_BUDGET_MS;
|
|
265
|
+
const turnIdleTimeoutMs = opts.turnIdleTimeoutMs ?? TURN_IDLE_TIMEOUT_MS;
|
|
266
|
+
const setTimeoutFn = opts.setTimeoutFn ?? ((cb: () => void, ms: number) => setTimeout(cb, ms));
|
|
267
|
+
const clearTimeoutFn =
|
|
268
|
+
opts.clearTimeoutFn ??
|
|
269
|
+
((handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>));
|
|
270
|
+
const onMessage = opts.onMessage;
|
|
271
|
+
const onPrompt = opts.onPrompt;
|
|
272
|
+
const onPromptResolved = opts.onPromptResolved;
|
|
273
|
+
const onStatusChange = opts.onStatusChange;
|
|
274
|
+
const onTitleChange = opts.onTitleChange;
|
|
275
|
+
const onDeleted = opts.onDeleted;
|
|
276
|
+
const onError = opts.onError;
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Project yang refresh skill-nya tertunda karena ada turn aktif saat skill
|
|
280
|
+
* dipasang. Dijalankan otomatis begitu seluruh turn di Project selesai.
|
|
281
|
+
*/
|
|
282
|
+
const pendingSkillRefresh = new Set<string>();
|
|
283
|
+
/** Timer refresh tertunda per Project (dibatalkan bila turn baru dimulai). */
|
|
284
|
+
const skillRefreshTimers = new Map<string, unknown>();
|
|
285
|
+
/** Jeda setelah turn terakhir selesai sebelum dispose (biarkan event penutup tuntas). */
|
|
286
|
+
const skillRefreshDelayMs = opts.skillRefreshDelayMs ?? SKILL_REFRESH_DELAY_MS;
|
|
287
|
+
const onSkillsRefreshed = opts.onSkillsRefreshed;
|
|
288
|
+
|
|
289
|
+
const onTurnChange = (sessionId: string, active: boolean): void => {
|
|
290
|
+
opts.onTurnChange?.(sessionId, active);
|
|
291
|
+
const projectId = store.getSession(sessionId);
|
|
292
|
+
if (!projectId.ok || !pendingSkillRefresh.has(projectId.data.projectId)) return;
|
|
293
|
+
if (active) {
|
|
294
|
+
// Turn baru dimulai sebelum timer jalan -> tunda lagi.
|
|
295
|
+
cancelScheduledSkillRefresh(projectId.data.projectId);
|
|
296
|
+
} else {
|
|
297
|
+
scheduleSkillRefresh(projectId.data.projectId);
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Pemetaan ocSessionId (opencode) -> id Session lokal.
|
|
303
|
+
*
|
|
304
|
+
* Selain Session yang dibuat lewat `POST /session`, map ini juga memuat
|
|
305
|
+
* child session yang dibuat opencode sendiri saat model memanggil `task`
|
|
306
|
+
* (sub-agent). Child session punya `sessionID` sendiri di SSE, sehingga tanpa
|
|
307
|
+
* pemetaan ini event-nya (termasuk `permission.asked`) akan terbuang.
|
|
308
|
+
*/
|
|
309
|
+
const ocToSession = new Map<string, string>();
|
|
310
|
+
/** Kebalikan `ocToSession`: id Session lokal -> seluruh ocSessionId miliknya. */
|
|
311
|
+
const sessionOcIds = new Map<string, Set<string>>();
|
|
312
|
+
/** Project yang event SSE-nya sudah disubscribe (subscription aktif). */
|
|
313
|
+
const activeSubscriptions = new Map<string, { active: boolean; unsubscribe: () => void }>();
|
|
314
|
+
/** Serialisasi kirim pesan per Session agar turn tidak tumpang tindih. */
|
|
315
|
+
const inflight = new Map<string, Promise<void>>();
|
|
316
|
+
/** Project yang sudah diproses saat server-nya keluar (hindari duplikasi). */
|
|
317
|
+
const exitNotified = new Set<string>();
|
|
318
|
+
const onMessagePart = opts.onMessagePart;
|
|
319
|
+
const onMessagePartDelta = opts.onMessagePartDelta;
|
|
320
|
+
|
|
321
|
+
/** State turn streaming per Session (finish/discard/fail + timer idle). */
|
|
322
|
+
const turns = new TurnStream({
|
|
323
|
+
store,
|
|
324
|
+
now,
|
|
325
|
+
onMessage,
|
|
326
|
+
onMessagePart,
|
|
327
|
+
onMessagePartDelta,
|
|
328
|
+
onError,
|
|
329
|
+
onTurnChange,
|
|
330
|
+
// Turn menunggu keputusan user (kartu permission/question pending) bukan
|
|
331
|
+
// keheningan — timer idle ditunda sampai kartu dijawab atau turn ditutup.
|
|
332
|
+
shouldFireIdle: (sessionId) => store.listPendingPrompts(sessionId).length === 0,
|
|
333
|
+
setTimeoutFn,
|
|
334
|
+
clearTimeoutFn,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
function updateStatus(sessionId: string, status: SessionStatus, changedAt: number): void {
|
|
338
|
+
const res = store.updateSessionStatus(sessionId, status, changedAt);
|
|
339
|
+
if (res.ok) onStatusChange?.(sessionId, status);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function updatePromptResolved(promptId: string): void {
|
|
343
|
+
store.updatePromptStatus(promptId, "resolved", now());
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Catat pemetaan ocSessionId -> Session lokal (juga untuk cleanup). */
|
|
347
|
+
function mapOcSession(ocSessionId: string, sessionId: string): void {
|
|
348
|
+
ocToSession.set(ocSessionId, sessionId);
|
|
349
|
+
let owned = sessionOcIds.get(sessionId);
|
|
350
|
+
if (!owned) {
|
|
351
|
+
owned = new Set<string>();
|
|
352
|
+
sessionOcIds.set(sessionId, owned);
|
|
353
|
+
}
|
|
354
|
+
owned.add(ocSessionId);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Lepas seluruh ocSessionId (induk + child) milik satu Session lokal. */
|
|
358
|
+
function unmapOcSessions(sessionId: string): void {
|
|
359
|
+
const owned = sessionOcIds.get(sessionId);
|
|
360
|
+
if (!owned) return;
|
|
361
|
+
for (const ocId of owned) ocToSession.delete(ocId);
|
|
362
|
+
sessionOcIds.delete(sessionId);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Daftarkan child session (sub-agent `task`) ke Session lokal induknya.
|
|
367
|
+
*
|
|
368
|
+
* opencode membuat session baru untuk setiap sub-agent dengan `parentID`
|
|
369
|
+
* menunjuk session pemanggil. Event-nya (`permission.asked`, `message.*`)
|
|
370
|
+
* memakai `sessionID` child, jadi tanpa pemetaan ini prompt izin sub-agent
|
|
371
|
+
* tidak akan pernah sampai ke Client dan turn menggantung sampai timeout.
|
|
372
|
+
* Rantai bersarang ikut tertangani karena induk sudah lebih dulu terpetakan.
|
|
373
|
+
*/
|
|
374
|
+
function registerChildSession(ev: OpenCodeEvent): void {
|
|
375
|
+
const info = field(ev, "info");
|
|
376
|
+
if (typeof info !== "object" || info === null) return;
|
|
377
|
+
const { id, parentID } = info as { id?: unknown; parentID?: unknown };
|
|
378
|
+
if (typeof id !== "string" || typeof parentID !== "string") return;
|
|
379
|
+
if (ocToSession.has(id)) return; // sudah terpetakan (mis. session induk)
|
|
380
|
+
const sessionId = ocToSession.get(parentID);
|
|
381
|
+
if (!sessionId) return; // induk bukan Session milik KCG Code — abaikan
|
|
382
|
+
mapOcSession(id, sessionId);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Handler event SSE dari satu server (per Project).
|
|
387
|
+
* Selain permission/question, kini memproses streaming:
|
|
388
|
+
* - `session.created` dengan `parentID` -> petakan child sub-agent ke induk.
|
|
389
|
+
* - `message.updated` role=assistant -> catat id pesan assistant (turn).
|
|
390
|
+
* - `message.part.updated` -> forward part ke Client (via onMessagePart).
|
|
391
|
+
* - `message.part.delta` -> potongan teks streaming (via onMessagePartDelta).
|
|
392
|
+
*/
|
|
393
|
+
function handleEvent(_projectId: string, ev: OpenCodeEvent): void {
|
|
394
|
+
if (ev.type === "session.created") {
|
|
395
|
+
registerChildSession(ev);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (ev.type === "session.updated") {
|
|
399
|
+
// Judul Session dibuat otomatis opencode setelah prompt pertama
|
|
400
|
+
// (summarization). Hanya Session AKAR yang dipetakan yang diproses —
|
|
401
|
+
// child sub-agent tidak mewakili percakapan user.
|
|
402
|
+
const info = field(ev, "info");
|
|
403
|
+
if (typeof info !== "object" || info === null) return;
|
|
404
|
+
const { id, title } = info as { id?: unknown; title?: unknown };
|
|
405
|
+
if (typeof id !== "string" || typeof title !== "string") return;
|
|
406
|
+
if (title.trim() === "") return;
|
|
407
|
+
// Placeholder (`New session - <ISO>`, judul lama "KCG Code Session")
|
|
408
|
+
// bukan judul percakapan — abaikan agar tidak menimpa judul sementara.
|
|
409
|
+
// Judul hasil opencode selalu menggantikan judul sementara lokal.
|
|
410
|
+
if (isPlaceholderTitle(title)) return;
|
|
411
|
+
const sessionId = ocToSession.get(id);
|
|
412
|
+
if (sessionId === undefined) return;
|
|
413
|
+
// Hanya Session akar: judul child sub-agent bukan judul percakapan
|
|
414
|
+
// user (guard yang sama dengan `session.idle` di bawah).
|
|
415
|
+
const cur = store.getSession(sessionId);
|
|
416
|
+
if (!cur.ok || cur.data.ocSessionId !== id) return;
|
|
417
|
+
if (cur.data.title === title) return;
|
|
418
|
+
if (!store.updateSessionTitle(sessionId, title).ok) return;
|
|
419
|
+
onTitleChange?.(sessionId, title);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (ev.type === "session.idle") {
|
|
423
|
+
const ocId = field(ev, "sessionID", "sessionId");
|
|
424
|
+
if (typeof ocId !== "string") return;
|
|
425
|
+
const sessionId = ocToSession.get(ocId);
|
|
426
|
+
if (!sessionId) return;
|
|
427
|
+
// Sub-agent idle lebih dulu dari induknya; hanya idle Session akar yang
|
|
428
|
+
// menandai turn selesai, jika tidak turn terpotong di tengah.
|
|
429
|
+
const cur = store.getSession(sessionId);
|
|
430
|
+
if (!cur.ok || cur.data.ocSessionId !== ocId) return;
|
|
431
|
+
turns.finish(sessionId);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (ev.type === "session.error") {
|
|
435
|
+
const ocId = field(ev, "sessionID", "sessionId");
|
|
436
|
+
if (typeof ocId !== "string") return;
|
|
437
|
+
const sessionId = ocToSession.get(ocId);
|
|
438
|
+
if (!sessionId) return;
|
|
439
|
+
const err = field(ev, "error");
|
|
440
|
+
const errName =
|
|
441
|
+
typeof err === "object" && err !== null ? (err as { name?: unknown }).name : undefined;
|
|
442
|
+
// MessageAbortedError = turn dibatalkan (interrupt user / reject prompt).
|
|
443
|
+
// Bukan kegagalan: parts yang sudah ter-stream disimpan tanpa banner error.
|
|
444
|
+
const aborted = errName === "MessageAbortedError";
|
|
445
|
+
const message = describeSessionError(ev);
|
|
446
|
+
if (turns.has(sessionId)) {
|
|
447
|
+
if (aborted) turns.finish(sessionId);
|
|
448
|
+
else turns.noteError(sessionId, message);
|
|
449
|
+
} else if (!aborted) {
|
|
450
|
+
// Tanpa turn aktif, abort berarti turn sudah ditutup interrupt/stop -
|
|
451
|
+
// jangan tampilkan banner "Pemrosesan prompt dibatalkan" ke Client.
|
|
452
|
+
onError?.(sessionId, message);
|
|
453
|
+
}
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (ev.type === "session.status") {
|
|
457
|
+
// Saat provider model error yang bisa di-retry (rate limit, kredit habis,
|
|
458
|
+
// cooldown, 5xx), opencode memancarkan `status: { type: "retry", ... }`
|
|
459
|
+
// alih-alih langsung gagal — turn TETAP berjalan selama backoff retry.
|
|
460
|
+
// Tanpa banner ini Client menunggu diam-diam sampai retry habis atau
|
|
461
|
+
// batas idle turn. Status apa pun (busy/retry) adalah tanda hidup:
|
|
462
|
+
// reset timer idle agar turn panjang tidak dipotong.
|
|
463
|
+
const ocId = field(ev, "sessionID", "sessionId");
|
|
464
|
+
if (typeof ocId !== "string") return;
|
|
465
|
+
const sessionId = ocToSession.get(ocId);
|
|
466
|
+
if (!sessionId) return;
|
|
467
|
+
turns.keepAlive(sessionId);
|
|
468
|
+
const status = field(ev, "status");
|
|
469
|
+
const message = retryStatusMessage(status);
|
|
470
|
+
if (message) onError?.(sessionId, message);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (ev.type === "message.updated") {
|
|
474
|
+
const ocId = field(ev, "sessionID", "sessionId");
|
|
475
|
+
if (typeof ocId !== "string") return;
|
|
476
|
+
const sessionId = ocToSession.get(ocId);
|
|
477
|
+
if (!sessionId) return;
|
|
478
|
+
const info = field(ev, "info");
|
|
479
|
+
if (info && typeof info === "object") {
|
|
480
|
+
const role = (info as { role?: unknown }).role;
|
|
481
|
+
const id = (info as { id?: unknown }).id;
|
|
482
|
+
if (role === "assistant" && typeof id === "string") {
|
|
483
|
+
turns.noteAssistantMessage(sessionId, id);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (ev.type === "message.part.updated") {
|
|
489
|
+
const ocId = field(ev, "sessionID", "sessionId");
|
|
490
|
+
if (typeof ocId !== "string") return;
|
|
491
|
+
const sessionId = ocToSession.get(ocId);
|
|
492
|
+
if (!sessionId) return;
|
|
493
|
+
const part = field(ev, "part");
|
|
494
|
+
if (typeof part !== "object" || part === null) return;
|
|
495
|
+
const p = part as MessagePart & { messageID?: unknown };
|
|
496
|
+
const messageId = p.messageID;
|
|
497
|
+
if (typeof messageId !== "string") return;
|
|
498
|
+
turns.acceptPart(sessionId, messageId, p);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (ev.type === "message.part.delta") {
|
|
502
|
+
const ocId = field(ev, "sessionID", "sessionId");
|
|
503
|
+
if (typeof ocId !== "string") return;
|
|
504
|
+
const sessionId = ocToSession.get(ocId);
|
|
505
|
+
if (!sessionId) return;
|
|
506
|
+
const messageId = field(ev, "messageID", "messageId");
|
|
507
|
+
const partId = field(ev, "partID", "partId");
|
|
508
|
+
const name = field(ev, "field");
|
|
509
|
+
const delta = field(ev, "delta");
|
|
510
|
+
if (
|
|
511
|
+
typeof messageId !== "string" ||
|
|
512
|
+
typeof partId !== "string" ||
|
|
513
|
+
typeof name !== "string" ||
|
|
514
|
+
typeof delta !== "string"
|
|
515
|
+
) {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
turns.acceptDelta(sessionId, messageId, partId, name, delta);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
// v1 & v2 memakai id request yang sama (`per_...`), dan `prompts.id` adalah
|
|
522
|
+
// PRIMARY KEY — jadi bila server memancarkan keduanya, insert kedua gagal
|
|
523
|
+
// dan kartu tidak terduplikasi.
|
|
524
|
+
if (ev.type === "permission.asked" || ev.type === "permission.v2.asked") {
|
|
525
|
+
const ocId = field(ev, "sessionID", "sessionId");
|
|
526
|
+
if (typeof ocId !== "string") return;
|
|
527
|
+
const sessionId = ocToSession.get(ocId);
|
|
528
|
+
if (!sessionId) return;
|
|
529
|
+
// Turn sedang MENUNGGU keputusan user — bukan sunyi: reset timer idle
|
|
530
|
+
// agar turn tidak dipotong saat user lambat menjawab kartu izin.
|
|
531
|
+
turns.keepAlive(sessionId);
|
|
532
|
+
const prompt = promptFromPermission(ev, sessionId, now());
|
|
533
|
+
if (!store.insertPrompt(prompt).ok) return; // duplikat -> abaikan
|
|
534
|
+
onPrompt?.(prompt);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (ev.type === "question.asked" || ev.type === "question.v2.asked") {
|
|
538
|
+
const ocId = field(ev, "sessionID", "sessionId");
|
|
539
|
+
if (typeof ocId !== "string") return;
|
|
540
|
+
const sessionId = ocToSession.get(ocId);
|
|
541
|
+
if (!sessionId) return;
|
|
542
|
+
// Sama seperti permission: menunggu jawaban user = tanda hidup.
|
|
543
|
+
turns.keepAlive(sessionId);
|
|
544
|
+
const prompt = promptFromQuestion(ev, sessionId, now());
|
|
545
|
+
if (!store.insertPrompt(prompt).ok) return;
|
|
546
|
+
onPrompt?.(prompt);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Pastikan server Project hidup; subscribe event SSE bila belum aktif.
|
|
552
|
+
* `refreshConfig` = true: bila file config opencode berubah, server
|
|
553
|
+
* di-restart dulu (dipakai listModels/listAgents agar daftar ikut
|
|
554
|
+
* terbaca ulang — `opencode serve` tidak hot-reload config). Setelah
|
|
555
|
+
* restart, subscription SSE lama dibuang dan diganti milik server baru.
|
|
556
|
+
*/
|
|
557
|
+
async function ensureServerFor(
|
|
558
|
+
projectId: string,
|
|
559
|
+
projectPath: string,
|
|
560
|
+
opts: { refreshConfig?: boolean } = {},
|
|
561
|
+
): Promise<Result<{ client: OpenCodeClient }>> {
|
|
562
|
+
const prevClient = servers.getServer(projectId)?.client;
|
|
563
|
+
const ensure = opts.refreshConfig
|
|
564
|
+
? servers.ensureFreshServer(projectId, projectPath)
|
|
565
|
+
: servers.ensureServer(projectId, projectPath);
|
|
566
|
+
const res = await ensure;
|
|
567
|
+
if (!res.ok) return { ok: false, error: res.error };
|
|
568
|
+
const handle = res.data;
|
|
569
|
+
// Server headless dapat diganti (crash lalu ensure ulang, resume, atau
|
|
570
|
+
// restart config). Bila client berbeda dari sebelumnya, subscription
|
|
571
|
+
// lama tidak lagi menempel — buang dan subscribe ulang ke instance baru.
|
|
572
|
+
const sub = activeSubscriptions.get(projectId);
|
|
573
|
+
if (prevClient && handle.client !== prevClient) {
|
|
574
|
+
sub?.unsubscribe();
|
|
575
|
+
activeSubscriptions.delete(projectId);
|
|
576
|
+
}
|
|
577
|
+
if (!activeSubscriptions.get(projectId)?.active) {
|
|
578
|
+
const prev = activeSubscriptions.get(projectId);
|
|
579
|
+
prev?.unsubscribe();
|
|
580
|
+
const unsubscribe = handle.client.subscribeEvents((ev) => handleEvent(projectId, ev));
|
|
581
|
+
activeSubscriptions.set(projectId, { active: true, unsubscribe });
|
|
582
|
+
}
|
|
583
|
+
// Server baru (atau hasil restart) — izinkan event exit berikutnya diproses.
|
|
584
|
+
exitNotified.delete(projectId);
|
|
585
|
+
return { ok: true, data: { client: handle.client } };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function enqueue(sessionId: string, task: () => Promise<void>): void {
|
|
589
|
+
const prev = inflight.get(sessionId) ?? Promise.resolve();
|
|
590
|
+
const next = prev.then(task).finally(() => {
|
|
591
|
+
if (inflight.get(sessionId) === next) inflight.delete(sessionId);
|
|
592
|
+
});
|
|
593
|
+
inflight.set(sessionId, next);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async function createSession(req: CreateSessionRequest): Promise<CreateSessionResult> {
|
|
597
|
+
// (1) tipe CLI_Agent didukung (Requirement 1.3)
|
|
598
|
+
if (!SUPPORTED_AGENT_TYPES.includes(req.agentType)) {
|
|
599
|
+
return { ok: false, error: "UNSUPPORTED_AGENT_TYPE" };
|
|
600
|
+
}
|
|
601
|
+
// (2) Project ada di Session_Store (Requirement 10.10)
|
|
602
|
+
const projectRes = store.getProjectById(req.projectId);
|
|
603
|
+
if (!projectRes.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
|
|
604
|
+
const project = projectRes.data;
|
|
605
|
+
// (3) direktori kerja Project masih ada di filesystem (Requirement 10.11)
|
|
606
|
+
let dirExists = false;
|
|
607
|
+
try {
|
|
608
|
+
dirExists = existsSync(project.path) && statSync(project.path).isDirectory();
|
|
609
|
+
} catch {
|
|
610
|
+
dirExists = false;
|
|
611
|
+
}
|
|
612
|
+
if (!dirExists) return { ok: false, error: "PROJECT_DIR_NOT_FOUND" };
|
|
613
|
+
|
|
614
|
+
// (4) pastikan server headless untuk Project (spawn bila perlu)
|
|
615
|
+
const serverRes = await ensureServerFor(project.id, project.path);
|
|
616
|
+
if (!serverRes.ok) return { ok: false, error: serverRes.error };
|
|
617
|
+
const client = serverRes.data.client;
|
|
618
|
+
|
|
619
|
+
// (5) validasi model pilihan terhadap daftar provider server (Req: pilih
|
|
620
|
+
// model). `null` = pakai default opencode. Daftar provider gagal
|
|
621
|
+
// diambil -> ditolak agar Session tidak lahir dengan model mati.
|
|
622
|
+
const model: SessionModel | null = req.model ?? null;
|
|
623
|
+
if (model) {
|
|
624
|
+
const models = await client.listModels();
|
|
625
|
+
if (!models.ok) return { ok: false, error: models.error };
|
|
626
|
+
const known = models.data.some(
|
|
627
|
+
(m) => m.providerID === model.providerID && m.modelID === model.modelID,
|
|
628
|
+
);
|
|
629
|
+
if (!known) return { ok: false, error: "MODEL_NOT_FOUND" };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// (6) buat Session di server headless — TANPA judul, agar opencode
|
|
633
|
+
// meng-generate judul dari prompt pertama (lihat lib/session-title.ts).
|
|
634
|
+
const created = await client.createSession();
|
|
635
|
+
if (!created.ok) return { ok: false, error: created.error };
|
|
636
|
+
|
|
637
|
+
const sessionId = randomUUID();
|
|
638
|
+
const createdAt = now();
|
|
639
|
+
// Judul awal opencode berupa placeholder (`New session - <ISO>`) — tidak
|
|
640
|
+
// disimpan. Judul datang dari SSE `session.updated`; sementara itu
|
|
641
|
+
// `sendFreeTextInput` memberi judul turunan prompt pertama.
|
|
642
|
+
const remoteTitle =
|
|
643
|
+
typeof created.data.title === "string" && !isPlaceholderTitle(created.data.title)
|
|
644
|
+
? created.data.title
|
|
645
|
+
: null;
|
|
646
|
+
const session: Session = {
|
|
647
|
+
id: sessionId,
|
|
648
|
+
projectId: project.id,
|
|
649
|
+
agentType: req.agentType,
|
|
650
|
+
cwd: project.path,
|
|
651
|
+
status: "running",
|
|
652
|
+
ocSessionId: created.data.id,
|
|
653
|
+
title: remoteTitle,
|
|
654
|
+
model,
|
|
655
|
+
agent: req.agent ?? null,
|
|
656
|
+
createdAt,
|
|
657
|
+
updatedAt: createdAt,
|
|
658
|
+
};
|
|
659
|
+
const ins = store.insertSession(session);
|
|
660
|
+
if (!ins.ok) {
|
|
661
|
+
// Abort session remote agar tidak jadi yatim bila persistensi gagal.
|
|
662
|
+
void client.abortSession(created.data.id);
|
|
663
|
+
return ins;
|
|
664
|
+
}
|
|
665
|
+
mapOcSession(created.data.id, sessionId);
|
|
666
|
+
return { ok: true, session };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function listSessions(): Session[] {
|
|
670
|
+
return store.listSessions();
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function getSession(sessionId: string): Result<Session> {
|
|
674
|
+
return store.getSession(sessionId);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function stopSession(sessionId: string): SimpleResult {
|
|
678
|
+
const cur = store.getSession(sessionId);
|
|
679
|
+
if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
|
|
680
|
+
if (cur.data.status !== "running") return { ok: false, error: "SESSION_NOT_RUNNING" };
|
|
681
|
+
// Abort turn berjalan (best-effort, tidak menunggu).
|
|
682
|
+
if (cur.data.ocSessionId) {
|
|
683
|
+
const handle = servers.getServer(cur.data.projectId);
|
|
684
|
+
void handle?.client.abortSession(cur.data.ocSessionId);
|
|
685
|
+
}
|
|
686
|
+
// Simpan parts yang sudah terkumpul sebelum Session ditutup.
|
|
687
|
+
turns.finish(sessionId);
|
|
688
|
+
// Lepas induk + seluruh child sub-agent milik Session ini.
|
|
689
|
+
unmapOcSessions(sessionId);
|
|
690
|
+
updateStatus(sessionId, "stopped", now());
|
|
691
|
+
return { ok: true };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Hentikan balasan model yang sedang berlangsung (interrupt ala opencode):
|
|
696
|
+
* - Turn remote di-abort (best-effort, tidak menunggu).
|
|
697
|
+
* - Parts yang sudah ter-stream DIBUANG (tidak disimpan) — user membatalkan
|
|
698
|
+
* balasan, sehingga respon parsial tidak boleh muncul di riwayat/UI.
|
|
699
|
+
* - Session TETAP `running` dan pemetaan SSE dipertahankan — user bisa
|
|
700
|
+
* langsung mengirim pesan baru tanpa Start ulang.
|
|
701
|
+
* Idempoten: tanpa turn aktif tidak ada yang berubah (tetap `ok`).
|
|
702
|
+
*/
|
|
703
|
+
function interruptSession(sessionId: string): SimpleResult {
|
|
704
|
+
const cur = store.getSession(sessionId);
|
|
705
|
+
if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
|
|
706
|
+
// Abort turn berjalan (best-effort, tidak menunggu).
|
|
707
|
+
if (cur.data.status === "running" && cur.data.ocSessionId) {
|
|
708
|
+
const handle = servers.getServer(cur.data.projectId);
|
|
709
|
+
void handle?.client.abortSession(cur.data.ocSessionId);
|
|
710
|
+
}
|
|
711
|
+
// Buang turn parsial (tidak disimpan) + broadcast turn tidak aktif.
|
|
712
|
+
turns.discard(sessionId);
|
|
713
|
+
return { ok: true };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Menghidupkan kembali Session yang `stopped`/`crashed` dengan riwayat
|
|
718
|
+
* percakapan utuh:
|
|
719
|
+
*
|
|
720
|
+
* 1. Server headless Project di-ensure (spawn ulang bila sudah mati) —
|
|
721
|
+
* storage session opencode bertahan di disk project, sehingga ocSessionId
|
|
722
|
+
* lama biasanya masih dikenal server baru.
|
|
723
|
+
* 2. ocSessionId diverifikasi via `GET /session/{id}`. Bila masih ada,
|
|
724
|
+
* dipakai lagi (riwayat server opencode tetap nyambung). Bila tidak,
|
|
725
|
+
* dibuat sesi remote baru dan `oc_session_id` di DB diperbarui — pesan
|
|
726
|
+
* lama di Session_Store tidak tersentuh.
|
|
727
|
+
* 3. Pemetaan SSE diaktifkan kembali lalu status -> `running`.
|
|
728
|
+
*/
|
|
729
|
+
async function resumeSession(sessionId: string): Promise<SimpleResult> {
|
|
730
|
+
const cur = store.getSession(sessionId);
|
|
731
|
+
if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
|
|
732
|
+
if (cur.data.status === "running") return { ok: false, error: "SESSION_ALREADY_RUNNING" };
|
|
733
|
+
const project = store.getProjectById(cur.data.projectId);
|
|
734
|
+
if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
|
|
735
|
+
|
|
736
|
+
// (1) Pastikan server headless Project hidup (spawn ulang bila perlu).
|
|
737
|
+
const serverRes = await ensureServerFor(project.data.id, project.data.path);
|
|
738
|
+
if (!serverRes.ok) return { ok: false, error: serverRes.error };
|
|
739
|
+
|
|
740
|
+
// (2) Verifikasi ocSessionId lama; bila tidak dikenal -> sesi remote baru.
|
|
741
|
+
let ocSessionId = cur.data.ocSessionId;
|
|
742
|
+
if (ocSessionId) {
|
|
743
|
+
const remote = await serverRes.data.client.getSession(ocSessionId);
|
|
744
|
+
if (remote.ok) {
|
|
745
|
+
mapOcSession(ocSessionId, sessionId);
|
|
746
|
+
} else {
|
|
747
|
+
ocSessionId = null;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
if (!ocSessionId) {
|
|
751
|
+
// Sesi remote baru (yang lama hilang). Tanpa judul: bila Session belum
|
|
752
|
+
// punya judul, opencode akan meng-generate-nya dari prompt berikutnya.
|
|
753
|
+
const created = await serverRes.data.client.createSession();
|
|
754
|
+
if (!created.ok) return { ok: false, error: created.error };
|
|
755
|
+
ocSessionId = created.data.id;
|
|
756
|
+
mapOcSession(ocSessionId, sessionId);
|
|
757
|
+
const upd = store.updateSessionOcId(sessionId, ocSessionId);
|
|
758
|
+
if (!upd.ok) return { ok: false, error: upd.error };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// (3) Status kembali running.
|
|
762
|
+
updateStatus(sessionId, "running", now());
|
|
763
|
+
return { ok: true };
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Lepaskan seluruh sumber daya milik satu Project — dipakai rute
|
|
768
|
+
* `DELETE /api/projects/:id` sebelum baris Project dihapus:
|
|
769
|
+
*
|
|
770
|
+
* 1. Setiap Session milik Project dihapus lewat `deleteSession` sehingga
|
|
771
|
+
* sesi remote opencode, lampiran gambar, dan baris anaknya ikut bersih.
|
|
772
|
+
* Kegagalan menghentikan proses (error diteruskan) supaya tidak ada
|
|
773
|
+
* session opencode yatim — user bisa mencoba lagi.
|
|
774
|
+
* 2. Server headless Project dihentikan; tanpa Session ia tidak lagi
|
|
775
|
+
* dipakai dan tidak perlu terus memakan memori.
|
|
776
|
+
*/
|
|
777
|
+
async function releaseProject(projectId: string): Promise<SimpleResult> {
|
|
778
|
+
if (!store.getProjectById(projectId).ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
|
|
779
|
+
for (const session of store.listProjectSessions(projectId)) {
|
|
780
|
+
const res = await deleteSession(session.id);
|
|
781
|
+
if (!res.ok) return res;
|
|
782
|
+
}
|
|
783
|
+
await servers.stopServer(projectId);
|
|
784
|
+
// Subscription SSE ikut dilepas — server-nya sudah tidak ada.
|
|
785
|
+
const sub = activeSubscriptions.get(projectId);
|
|
786
|
+
if (sub) {
|
|
787
|
+
sub.unsubscribe();
|
|
788
|
+
activeSubscriptions.delete(projectId);
|
|
789
|
+
}
|
|
790
|
+
exitNotified.delete(projectId);
|
|
791
|
+
return { ok: true };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Daftar model yang tersedia untuk Project — server headless Project
|
|
796
|
+
* di-ensure lebih dulu (spawn bila perlu) karena provider/model dibaca dari
|
|
797
|
+
* konfigurasi opencode pada direktori Project. Bila file config opencode
|
|
798
|
+
* berubah sejak server hidup, proses di-restart agar daftar ikut terbaru
|
|
799
|
+
* (opencode serve tidak hot-reload config). Restart ditahan bila ada
|
|
800
|
+
* Session `running` agar turn yang sedang berjalan tidak terputus.
|
|
801
|
+
*/
|
|
802
|
+
async function listModels(projectId: string): Promise<Result<ModelOption[]>> {
|
|
803
|
+
const project = store.getProjectById(projectId);
|
|
804
|
+
if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
|
|
805
|
+
const hasRunning = store.listProjectSessions(projectId).some((s) => s.status === "running");
|
|
806
|
+
const serverRes = await ensureServerFor(project.data.id, project.data.path, {
|
|
807
|
+
refreshConfig: !hasRunning,
|
|
808
|
+
});
|
|
809
|
+
if (!serverRes.ok) return { ok: false, error: serverRes.error };
|
|
810
|
+
return serverRes.data.client.listModels();
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Daftar agent (mode) untuk Project — server headless Project di-ensure
|
|
815
|
+
* lebih dulu karena daftar agent dibaca dari konfigurasi opencode di
|
|
816
|
+
* direktori Project (agent kustom user ikut masuk). Config yang berubah
|
|
817
|
+
* memicu restart server agar daftar ikut terbaru (dengan guard yang sama
|
|
818
|
+
* seperti `listModels`).
|
|
819
|
+
*/
|
|
820
|
+
async function listAgents(projectId: string): Promise<Result<AgentOption[]>> {
|
|
821
|
+
const project = store.getProjectById(projectId);
|
|
822
|
+
if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
|
|
823
|
+
const hasRunning = store.listProjectSessions(projectId).some((s) => s.status === "running");
|
|
824
|
+
const serverRes = await ensureServerFor(project.data.id, project.data.path, {
|
|
825
|
+
refreshConfig: !hasRunning,
|
|
826
|
+
});
|
|
827
|
+
if (!serverRes.ok) return { ok: false, error: serverRes.error };
|
|
828
|
+
return serverRes.data.client.listAgents();
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Status MCP server Project — server headless di-ensure lebih dulu karena
|
|
833
|
+
* daftar MCP dibaca dari config opencode di direktori Project. Config yang
|
|
834
|
+
* berubah memicu restart (guard sama dengan `listModels`).
|
|
835
|
+
*/
|
|
836
|
+
async function listMcp(projectId: string): Promise<Result<McpServerInfo[]>> {
|
|
837
|
+
const project = store.getProjectById(projectId);
|
|
838
|
+
if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
|
|
839
|
+
const hasRunning = store.listProjectSessions(projectId).some((s) => s.status === "running");
|
|
840
|
+
const serverRes = await ensureServerFor(project.data.id, project.data.path, {
|
|
841
|
+
refreshConfig: !hasRunning,
|
|
842
|
+
});
|
|
843
|
+
if (!serverRes.ok) return { ok: false, error: serverRes.error };
|
|
844
|
+
return serverRes.data.client.listMcp();
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Skill yang terdaftar untuk Project. Skill di dalam folder Project
|
|
849
|
+
* ditandai `project` sehingga UI dapat membedakannya dari skill global.
|
|
850
|
+
*/
|
|
851
|
+
async function listSkills(projectId: string): Promise<Result<SkillInfo[]>> {
|
|
852
|
+
const project = store.getProjectById(projectId);
|
|
853
|
+
if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
|
|
854
|
+
const serverRes = await ensureServerFor(project.data.id, project.data.path);
|
|
855
|
+
if (!serverRes.ok) return { ok: false, error: serverRes.error };
|
|
856
|
+
return serverRes.data.client.listSkills(project.data.path);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function projectBusy(projectId: string): boolean {
|
|
860
|
+
return store.listSessions().some((s) => s.projectId === projectId && turns.has(s.id));
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function cancelScheduledSkillRefresh(projectId: string): void {
|
|
864
|
+
const t = skillRefreshTimers.get(projectId);
|
|
865
|
+
if (t !== undefined) {
|
|
866
|
+
clearTimeoutFn(t);
|
|
867
|
+
skillRefreshTimers.delete(projectId);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** Jadwalkan refresh tertunda bila Project sudah tidak punya turn aktif. */
|
|
872
|
+
function scheduleSkillRefresh(projectId: string): void {
|
|
873
|
+
if (projectBusy(projectId)) return;
|
|
874
|
+
cancelScheduledSkillRefresh(projectId);
|
|
875
|
+
const handle = setTimeoutFn(() => {
|
|
876
|
+
skillRefreshTimers.delete(projectId);
|
|
877
|
+
void runPendingSkillRefresh(projectId);
|
|
878
|
+
}, skillRefreshDelayMs);
|
|
879
|
+
skillRefreshTimers.set(projectId, handle);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async function runPendingSkillRefresh(projectId: string): Promise<void> {
|
|
883
|
+
if (!pendingSkillRefresh.has(projectId)) return;
|
|
884
|
+
// Turn baru sempat dimulai -> tunggu turn berikutnya selesai.
|
|
885
|
+
if (projectBusy(projectId)) return;
|
|
886
|
+
const handle = servers.getServer(projectId);
|
|
887
|
+
if (!handle) {
|
|
888
|
+
// Server sudah berhenti -> skill terbaca saat server di-spawn lagi.
|
|
889
|
+
pendingSkillRefresh.delete(projectId);
|
|
890
|
+
onSkillsRefreshed?.(projectId);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
const res = await handle.client.disposeInstance();
|
|
894
|
+
if (!res.ok) {
|
|
895
|
+
// Tetap tertunda; dicoba lagi setelah turn berikutnya selesai.
|
|
896
|
+
console.warn(`[kcg-code] refresh skill tertunda Project ${projectId} gagal:`, res.error);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
pendingSkillRefresh.delete(projectId);
|
|
900
|
+
onSkillsRefreshed?.(projectId);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
async function refreshSkills(projectId: string): Promise<Result<{ refreshed: boolean }>> {
|
|
904
|
+
const project = store.getProjectById(projectId);
|
|
905
|
+
if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
|
|
906
|
+
const handle = servers.getServer(projectId);
|
|
907
|
+
// Server belum hidup -> skill terbaca saat server di-spawn nanti.
|
|
908
|
+
if (!handle) {
|
|
909
|
+
pendingSkillRefresh.delete(projectId);
|
|
910
|
+
return { ok: true, data: { refreshed: true } };
|
|
911
|
+
}
|
|
912
|
+
if (projectBusy(projectId)) {
|
|
913
|
+
pendingSkillRefresh.add(projectId);
|
|
914
|
+
return { ok: true, data: { refreshed: false } };
|
|
915
|
+
}
|
|
916
|
+
cancelScheduledSkillRefresh(projectId);
|
|
917
|
+
const res = await handle.client.disposeInstance();
|
|
918
|
+
if (!res.ok) {
|
|
919
|
+
console.warn(`[kcg-code] refresh skill Project ${projectId} gagal:`, res.error);
|
|
920
|
+
// Coba lagi otomatis setelah turn berikutnya selesai.
|
|
921
|
+
pendingSkillRefresh.add(projectId);
|
|
922
|
+
return { ok: true, data: { refreshed: false } };
|
|
923
|
+
}
|
|
924
|
+
pendingSkillRefresh.delete(projectId);
|
|
925
|
+
return { ok: true, data: { refreshed: true } };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function hasPendingSkillRefresh(projectId: string): boolean {
|
|
929
|
+
return pendingSkillRefresh.has(projectId);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Cari file di Project untuk autocomplete `@file` — index pencarian
|
|
934
|
+
* milik server headless opencode (sesuai perilaku `@` di opencode TUI).
|
|
935
|
+
* `query` kosong juga valid (mengembalikan daftar awal).
|
|
936
|
+
*/
|
|
937
|
+
/**
|
|
938
|
+
* Konteks kerja Session (panel kanan). Ketiga sumbernya independen dan
|
|
939
|
+
* SELALU dibaca bersamaan; kegagalan salah satu tidak menggagalkan yang
|
|
940
|
+
* lain (mis. repo bukan git -> `git` kosong, task list tetap tampil).
|
|
941
|
+
* Session yang tidak berjalan -> hanya info git (server opencode tidak
|
|
942
|
+
* dinyalakan hanya untuk mengisi panel).
|
|
943
|
+
*/
|
|
944
|
+
async function sessionContext(sessionId: string): Promise<Result<SessionContext>> {
|
|
945
|
+
const cur = store.getSession(sessionId);
|
|
946
|
+
if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
|
|
947
|
+
const cwd = cur.data.cwd;
|
|
948
|
+
const handle = servers.getServer(cur.data.projectId);
|
|
949
|
+
|
|
950
|
+
const [git, todos, changes, mcp] = await Promise.all([
|
|
951
|
+
readGitInfo(cwd).catch(() => EMPTY_GIT_INFO),
|
|
952
|
+
handle ? handle.client.listTodos(sessionId) : Promise.resolve(null),
|
|
953
|
+
handle ? handle.client.fileStatus() : Promise.resolve(null),
|
|
954
|
+
handle ? handle.client.listMcp() : Promise.resolve(null),
|
|
955
|
+
]);
|
|
956
|
+
|
|
957
|
+
return {
|
|
958
|
+
ok: true,
|
|
959
|
+
data: {
|
|
960
|
+
git,
|
|
961
|
+
todos: todos?.ok ? todos.data : [],
|
|
962
|
+
changes: changes?.ok ? changes.data : [],
|
|
963
|
+
mcp: mcp?.ok ? mcp.data : [],
|
|
964
|
+
/** Panel perlu tahu data tool belum tersedia vs memang kosong. */
|
|
965
|
+
live: handle !== undefined && handle !== null,
|
|
966
|
+
},
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
async function findFiles(projectId: string, query: string): Promise<Result<string[]>> {
|
|
971
|
+
const project = store.getProjectById(projectId);
|
|
972
|
+
if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
|
|
973
|
+
const serverRes = await ensureServerFor(project.data.id, project.data.path);
|
|
974
|
+
if (!serverRes.ok) return { ok: false, error: serverRes.error };
|
|
975
|
+
return serverRes.data.client.findFiles(query);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Ganti model pilihan Session. Berlaku untuk prompt berikutnya (prompt_async
|
|
980
|
+
* selalu mengirim model tersimpan di Session), jadi tidak perlu restart.
|
|
981
|
+
*/
|
|
982
|
+
function setSessionModel(sessionId: string, model: SessionModel | null): SimpleResult {
|
|
983
|
+
const cur = store.getSession(sessionId);
|
|
984
|
+
if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
|
|
985
|
+
const upd = store.updateSessionModel(sessionId, model);
|
|
986
|
+
return upd.ok ? { ok: true } : { ok: false, error: upd.error };
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Ganti agent (mode) pilihan Session. Berlaku untuk prompt berikutnya
|
|
991
|
+
* (prompt_async selalu mengirim agent tersimpan di Session).
|
|
992
|
+
*/
|
|
993
|
+
function setSessionAgent(sessionId: string, agent: string | null): SimpleResult {
|
|
994
|
+
const cur = store.getSession(sessionId);
|
|
995
|
+
if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
|
|
996
|
+
if (agent !== null && agent.trim() === "") return { ok: false, error: "INVALID_AGENT" };
|
|
997
|
+
const upd = store.updateSessionAgent(sessionId, agent);
|
|
998
|
+
return upd.ok ? { ok: true } : { ok: false, error: upd.error };
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Hapus Session permanen:
|
|
1003
|
+
*
|
|
1004
|
+
* 1. Turn yang sedang stream difinalisasi & pemetaan SSE dilepas.
|
|
1005
|
+
* 2. Pastikan server headless Project hidup — spawn ulang bila mati — lalu
|
|
1006
|
+
* `DELETE /session/{id}`: data session (termasuk seluruh pesan) ikut
|
|
1007
|
+
* dihapus di sisi opencode. Penghapusan remote bersifat wajib: bila
|
|
1008
|
+
* server tidak bisa di-spawn ulang atau menolak hapus, error diteruskan
|
|
1009
|
+
* dan data lokal TIDAK dihapus — supaya tidak ada session opencode yang
|
|
1010
|
+
* jadi yatim tanpa sepengetahuan user (bisa dicoba lagi kemudian).
|
|
1011
|
+
* 3. Baris Session + seluruh baris anaknya dihapus dari Session_Store.
|
|
1012
|
+
*/
|
|
1013
|
+
async function deleteSession(sessionId: string): Promise<SimpleResult> {
|
|
1014
|
+
const cur = store.getSession(sessionId);
|
|
1015
|
+
if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
|
|
1016
|
+
|
|
1017
|
+
turns.finish(sessionId);
|
|
1018
|
+
// Bila masih running, abort turn remote agar model berhenti dieksekusi.
|
|
1019
|
+
if (cur.data.status === "running" && cur.data.ocSessionId) {
|
|
1020
|
+
const runningHandle = servers.getServer(cur.data.projectId);
|
|
1021
|
+
void runningHandle?.client.abortSession(cur.data.ocSessionId);
|
|
1022
|
+
}
|
|
1023
|
+
unmapOcSessions(sessionId);
|
|
1024
|
+
|
|
1025
|
+
// (2) Hapus di server headless — wajib: server di-spawn ulang bila mati
|
|
1026
|
+
// agar data remote tidak tertinggal. Server hasil spawn dibiarkan
|
|
1027
|
+
// hidup (bukan di-stop) karena mungkin sedang dipakai operasi lain
|
|
1028
|
+
// dan hanya akan dihentikan saat shutdown.
|
|
1029
|
+
const ocSessionId = cur.data.ocSessionId;
|
|
1030
|
+
if (ocSessionId) {
|
|
1031
|
+
let client = servers.getServer(cur.data.projectId)?.client;
|
|
1032
|
+
if (!client) {
|
|
1033
|
+
const project = store.getProjectById(cur.data.projectId);
|
|
1034
|
+
if (!project.ok) return { ok: false, error: project.error };
|
|
1035
|
+
const serverRes = await ensureServerFor(project.data.id, project.data.path);
|
|
1036
|
+
if (!serverRes.ok) return { ok: false, error: serverRes.error };
|
|
1037
|
+
client = serverRes.data.client;
|
|
1038
|
+
}
|
|
1039
|
+
const remote = await client.deleteSession(ocSessionId);
|
|
1040
|
+
if (!remote.ok) return { ok: false, error: remote.error };
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// (3) Hapus lokal (transaksional: messages, prompts, history, session).
|
|
1044
|
+
const res = store.deleteSession(sessionId);
|
|
1045
|
+
if (res.ok) {
|
|
1046
|
+
// Lampiran gambar milik Session ikut dibersihkan (best effort).
|
|
1047
|
+
attachments?.removeSession(sessionId);
|
|
1048
|
+
onDeleted?.(sessionId);
|
|
1049
|
+
}
|
|
1050
|
+
return res;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
async function sendFreeTextInput(
|
|
1054
|
+
sessionId: string,
|
|
1055
|
+
text: string,
|
|
1056
|
+
files: string[] = [],
|
|
1057
|
+
images: string[] = [],
|
|
1058
|
+
): Promise<SimpleResult> {
|
|
1059
|
+
// Pesan berupa teks bebas dan/atau gambar: teks kosong diizinkan bila ada
|
|
1060
|
+
// gambar terlampir (mis. kirim gambar saja), selainnya wajib non-kosong.
|
|
1061
|
+
const hasText = text.trim().length > 0;
|
|
1062
|
+
const hasImages = images.length > 0;
|
|
1063
|
+
if (!hasText && !hasImages) {
|
|
1064
|
+
return { ok: false, error: "TEXT_EMPTY" };
|
|
1065
|
+
}
|
|
1066
|
+
if (hasText && text.length > MAX_FREE_TEXT_LENGTH) {
|
|
1067
|
+
return { ok: false, error: "TEXT_TOO_LONG" };
|
|
1068
|
+
}
|
|
1069
|
+
const cur = store.getSession(sessionId);
|
|
1070
|
+
if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
|
|
1071
|
+
if (cur.data.status !== "running") return { ok: false, error: "SESSION_NOT_ACTIVE" };
|
|
1072
|
+
const ocSessionId = cur.data.ocSessionId;
|
|
1073
|
+
if (!ocSessionId) return { ok: false, error: "SESSION_NOT_ACTIVE" };
|
|
1074
|
+
|
|
1075
|
+
// Pastikan server masih hidup SEBELUM menyimpan echo user.
|
|
1076
|
+
const handle = servers.getServer(cur.data.projectId);
|
|
1077
|
+
if (!handle) return { ok: false, error: "SESSION_NOT_ACTIVE" };
|
|
1078
|
+
|
|
1079
|
+
// Gambar upload dipetakan ke info + path absolut. Kegagalan resolve
|
|
1080
|
+
// (id tidak dikenal / sudah dihapus) menolak pengiriman tanpa echo.
|
|
1081
|
+
const imageInfos: { id: string; filename: string; mime: string; absPath: string }[] = [];
|
|
1082
|
+
for (const id of images) {
|
|
1083
|
+
const info = attachments?.info(sessionId, id);
|
|
1084
|
+
if (!info?.ok) return { ok: false, error: "ATTACHMENT_NOT_FOUND" };
|
|
1085
|
+
imageInfos.push(info.data);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// Echo pesan user (disimpan + dikirim ke Client) sebelum menunggu balasan.
|
|
1089
|
+
// Referensi @file ikut di-echo sebagai part `file` mime text/plain;
|
|
1090
|
+
// gambar upload sebagai part `file` mime image/* dengan `attachmentId`
|
|
1091
|
+
// agar renderer dapat memuat bytes lewat HTTP. Path relatif project
|
|
1092
|
+
// diubah ke file URL absolut — opencode mem-parse `url` dengan URL()
|
|
1093
|
+
// sehingga `file://rel/path` (host=rel) ditolak di Linux; host kosong
|
|
1094
|
+
// (`file:///abs/path`) valid.
|
|
1095
|
+
const promptFiles: OpenCodeFileRef[] = [];
|
|
1096
|
+
const userParts: MessagePart[] = [{ type: "text", text }];
|
|
1097
|
+
for (const filename of files) {
|
|
1098
|
+
const url = `file://${path.resolve(cur.data.cwd, filename)}`;
|
|
1099
|
+
userParts.push({ type: "file", mime: "text/plain", filename, url });
|
|
1100
|
+
promptFiles.push({ filename, mime: "text/plain", url });
|
|
1101
|
+
}
|
|
1102
|
+
/** Lampiran biner (zip, audio, …): tidak dikirim sebagai part. */
|
|
1103
|
+
const pathOnly: { filename: string; absPath: string }[] = [];
|
|
1104
|
+
for (const info of imageInfos) {
|
|
1105
|
+
// Jaring pengaman: `file://` harus menunjuk path absolut (host kosong).
|
|
1106
|
+
// `file://rel/path` membuat host="rel" yang ditolak opencode.
|
|
1107
|
+
const url = `file://${path.resolve(info.absPath)}`;
|
|
1108
|
+
// Echo selalu memakai mime asli (renderer memilih thumbnail / kartu).
|
|
1109
|
+
userParts.push({
|
|
1110
|
+
type: "file",
|
|
1111
|
+
mime: info.mime,
|
|
1112
|
+
filename: info.filename,
|
|
1113
|
+
url,
|
|
1114
|
+
attachmentId: info.id,
|
|
1115
|
+
});
|
|
1116
|
+
// Ke model: hanya mime yang diterima provider (gambar/PDF/teks). Mime
|
|
1117
|
+
// lain merusak turn (dan riwayat) dengan "functionality not supported".
|
|
1118
|
+
const modelMime = modelFacingMime(info.mime, info.filename);
|
|
1119
|
+
if (modelMime) promptFiles.push({ filename: info.filename, mime: modelMime, url });
|
|
1120
|
+
else pathOnly.push({ filename: info.filename, absPath: path.resolve(info.absPath) });
|
|
1121
|
+
}
|
|
1122
|
+
const userMessage: SessionMessage = {
|
|
1123
|
+
id: `usr_${randomUUID()}`,
|
|
1124
|
+
sessionId,
|
|
1125
|
+
role: "user",
|
|
1126
|
+
parts: userParts,
|
|
1127
|
+
createdAt: now(),
|
|
1128
|
+
};
|
|
1129
|
+
if (!store.insertMessage(userMessage).ok) return { ok: false, error: "MESSAGE_WRITE_FAILED" };
|
|
1130
|
+
onMessage?.(userMessage);
|
|
1131
|
+
|
|
1132
|
+
// Judul SEMENTARA sampai opencode mengirim judul hasil generate
|
|
1133
|
+
// (`session.updated`, biasanya beberapa detik). Bila masih placeholder /
|
|
1134
|
+
// null, turunkan dari teks prompt pertama. Gambar saja -> "Image".
|
|
1135
|
+
if (isPlaceholderTitle(cur.data.title)) {
|
|
1136
|
+
const candidate = deriveSessionTitle(text) ?? (hasImages ? "Image" : null);
|
|
1137
|
+
if (candidate && store.updateSessionTitle(sessionId, candidate).ok) {
|
|
1138
|
+
onTitleChange?.(sessionId, candidate);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// Turn sebelumnya yang belum idle ditutup dulu agar parts-nya tidak
|
|
1143
|
+
// tercampur ke turn baru dan tidak hilang.
|
|
1144
|
+
turns.finish(sessionId);
|
|
1145
|
+
|
|
1146
|
+
// Mulai turn streaming: parts dari SSE `message.part.updated` di-forward.
|
|
1147
|
+
// Jaring pengaman bila `session.idle` tidak pernah datang (mis. server
|
|
1148
|
+
// mati di tengah turn): batas IDLE, di-reset tiap tanda hidup — parts
|
|
1149
|
+
// yang sudah terkumpul tetap disimpan saat dipicu.
|
|
1150
|
+
turns.begin(sessionId, turnIdleTimeoutMs, friendlySendError("TURN_TIMEOUT"));
|
|
1151
|
+
|
|
1152
|
+
// Custom instruction Project dibaca saat kirim (bukan saat Session dibuat)
|
|
1153
|
+
// agar perubahan instruksi langsung berlaku di prompt berikutnya.
|
|
1154
|
+
const projectRes = store.getProjectById(cur.data.projectId);
|
|
1155
|
+
const system = projectRes.ok ? (projectRes.data.instructions ?? null) : null;
|
|
1156
|
+
|
|
1157
|
+
// File biner dirujuk lewat path agar model bisa memakai tool (bash/read)
|
|
1158
|
+
// bila perlu; teks yang tampil di bubble tetap teks asli pengguna.
|
|
1159
|
+
const promptText =
|
|
1160
|
+
pathOnly.length === 0
|
|
1161
|
+
? text
|
|
1162
|
+
: [
|
|
1163
|
+
text,
|
|
1164
|
+
"Attached files (saved on disk, not shown inline):",
|
|
1165
|
+
...pathOnly.map((f) => `- ${f.filename}: ${f.absPath}`),
|
|
1166
|
+
]
|
|
1167
|
+
.filter((line) => line !== "")
|
|
1168
|
+
.join("\n");
|
|
1169
|
+
|
|
1170
|
+
enqueue(sessionId, async () => {
|
|
1171
|
+
try {
|
|
1172
|
+
// `prompt_async` balas 204 begitu prompt diterima; hasil turn tiba
|
|
1173
|
+
// lewat SSE dan ditutup oleh `session.idle`.
|
|
1174
|
+
const res = await handle.client.promptAsync(
|
|
1175
|
+
ocSessionId,
|
|
1176
|
+
promptText,
|
|
1177
|
+
cur.data.model,
|
|
1178
|
+
promptFiles,
|
|
1179
|
+
cur.data.agent,
|
|
1180
|
+
system,
|
|
1181
|
+
);
|
|
1182
|
+
if (!res.ok) {
|
|
1183
|
+
turns.fail(sessionId, friendlySendError(res.error ?? "OC_PROMPT_ASYNC_FAILED"));
|
|
1184
|
+
}
|
|
1185
|
+
} catch (e) {
|
|
1186
|
+
turns.fail(sessionId, friendlySendError(`SEND_FAILED: ${(e as Error).message}`));
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
return { ok: true };
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
async function resolvePrompt(
|
|
1194
|
+
sessionId: string,
|
|
1195
|
+
promptId: string,
|
|
1196
|
+
response: PromptResponse,
|
|
1197
|
+
): Promise<SimpleResult> {
|
|
1198
|
+
const p = store.getPrompt(promptId);
|
|
1199
|
+
if (!p.ok) return { ok: false, error: "PROMPT_NOT_FOUND" };
|
|
1200
|
+
if (p.data.sessionId !== sessionId) return { ok: false, error: "PROMPT_NOT_FOUND" };
|
|
1201
|
+
if (p.data.status !== "pending") return { ok: false, error: "PROMPT_ALREADY_RESOLVED" };
|
|
1202
|
+
|
|
1203
|
+
const cur = store.getSession(sessionId);
|
|
1204
|
+
if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
|
|
1205
|
+
const handle = servers.getServer(cur.data.projectId);
|
|
1206
|
+
if (!handle) return { ok: false, error: "SESSION_NOT_ACTIVE" };
|
|
1207
|
+
|
|
1208
|
+
// Kumpulkan request pending yang identik (grup kartu yang sama di UI):
|
|
1209
|
+
// jawaban satu kartu diteruskan ke seluruh anggota grup. Tanpa ini,
|
|
1210
|
+
// kartu kembar yang tertinggal di server membuat turn berikutnya
|
|
1211
|
+
// menggantung menunggu keputusan yang sudah diambil user.
|
|
1212
|
+
const targets = store
|
|
1213
|
+
.listPendingPrompts(sessionId)
|
|
1214
|
+
.filter((q) => q.id !== promptId && permissionGroupKey(q) === permissionGroupKey(p.data));
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* Reply satu request permission. `always` = "Always allow" — opencode
|
|
1218
|
+
* mengingat pola yang disetujui sehingga request identik berikutnya tidak
|
|
1219
|
+
* lagi menampilkan kartu.
|
|
1220
|
+
*/
|
|
1221
|
+
const replyPermission = (id: string, r: PromptResponse): Promise<SimpleResult> => {
|
|
1222
|
+
if (r === "approve") return handle.client.replyPermission(id, "once");
|
|
1223
|
+
if (r === "always") return handle.client.replyPermission(id, "always");
|
|
1224
|
+
if (r === "deny" || r === "cancel") {
|
|
1225
|
+
// Cancel == tolak (tanpa meneruskan input), setara deny di mode TUI.
|
|
1226
|
+
return handle.client.replyPermission(id, "reject");
|
|
1227
|
+
}
|
|
1228
|
+
return Promise.resolve({ ok: false, error: "INVALID_PROMPT_RESPONSE" });
|
|
1229
|
+
};
|
|
1230
|
+
|
|
1231
|
+
let res: SimpleResult;
|
|
1232
|
+
if (p.data.kind === "permission") {
|
|
1233
|
+
res = await replyPermission(promptId, response);
|
|
1234
|
+
} else {
|
|
1235
|
+
// kind === "question" — opsi harus bagian dari daftar (Requirement 6.6).
|
|
1236
|
+
// Opsi tidak dikenal TIDAK ditolak keras bila daftar tersimpan tidak
|
|
1237
|
+
// cocok dengan opsi live (baris DB lama / skema server berubah):
|
|
1238
|
+
// diteruskan apa adanya ke opencode yang tahu daftar sebenarnya —
|
|
1239
|
+
// menolak di sini membuat tombol opsi terasa mati tanpa feedback.
|
|
1240
|
+
if (typeof response === "object" && response !== null) {
|
|
1241
|
+
if ("answers" in response) {
|
|
1242
|
+
// Multi-question: SATU array label per pertanyaan — diteruskan
|
|
1243
|
+
// apa adanya ke reply API (skema Question.Reply).
|
|
1244
|
+
res = await handle.client.replyQuestion(promptId, response.answers);
|
|
1245
|
+
} else if ("options" in response) {
|
|
1246
|
+
// Multi-select: kirim seluruh label terpilih (urut tampilan).
|
|
1247
|
+
res = await handle.client.replyQuestion(promptId, [response.options]);
|
|
1248
|
+
} else {
|
|
1249
|
+
res = await handle.client.replyQuestion(promptId, [[response.option]]);
|
|
1250
|
+
}
|
|
1251
|
+
} else if (response === "cancel") {
|
|
1252
|
+
res = await handle.client.rejectQuestion(promptId);
|
|
1253
|
+
} else {
|
|
1254
|
+
return { ok: false, error: "INVALID_PROMPT_RESPONSE" };
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
if (!res.ok) {
|
|
1258
|
+
if (res.error === OC_REQUEST_GONE) {
|
|
1259
|
+
// opencode tidak lagi menunggu request ini (404): sudah dijawab dari
|
|
1260
|
+
// klien lain, dibatalkan karena turn di-abort, atau server opencode
|
|
1261
|
+
// di-restart. Tanpa penutupan di sini, baris DB tetap `pending`:
|
|
1262
|
+
// kartu tidak bisa dijawab MAUPUN di-Skip (keduanya kena 404) dan
|
|
1263
|
+
// composer tetap tersembunyi — session terkunci. Tutup prompt (dan
|
|
1264
|
+
// kembarannya) secara lokal supaya UI pulih.
|
|
1265
|
+
updatePromptResolved(promptId);
|
|
1266
|
+
for (const twin of targets) {
|
|
1267
|
+
updatePromptResolved(twin.id);
|
|
1268
|
+
onPromptResolved?.(sessionId, twin.id);
|
|
1269
|
+
}
|
|
1270
|
+
return { ok: false, error: "PROMPT_EXPIRED" };
|
|
1271
|
+
}
|
|
1272
|
+
return { ok: false, error: res.error };
|
|
1273
|
+
}
|
|
1274
|
+
updatePromptResolved(promptId);
|
|
1275
|
+
// Jawaban question cukup lewat reply API — tidak di-echo ke riwayat chat
|
|
1276
|
+
// agar transcript bersih (agent tetap menerima jawabannya).
|
|
1277
|
+
// Fan-out best-effort ke kartu kembar; kegagalan satu request tidak
|
|
1278
|
+
// menggagalkan jawaban utama yang sudah diterima server.
|
|
1279
|
+
for (const twin of targets) {
|
|
1280
|
+
const twinRes =
|
|
1281
|
+
p.data.kind === "permission"
|
|
1282
|
+
? await replyPermission(twin.id, response)
|
|
1283
|
+
: typeof response === "object" && response !== null
|
|
1284
|
+
? "answers" in response
|
|
1285
|
+
? await handle.client.replyQuestion(twin.id, response.answers)
|
|
1286
|
+
: "options" in response
|
|
1287
|
+
? await handle.client.replyQuestion(twin.id, [response.options])
|
|
1288
|
+
: await handle.client.replyQuestion(twin.id, [[response.option]])
|
|
1289
|
+
: await handle.client.rejectQuestion(twin.id);
|
|
1290
|
+
if (twinRes.ok) {
|
|
1291
|
+
updatePromptResolved(twin.id);
|
|
1292
|
+
onPromptResolved?.(sessionId, twin.id);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
return { ok: true };
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/** Requirement 2.4: seluruh Session `running` tanpa server -> `crashed`. */
|
|
1299
|
+
function reconcileOnStartup(): void {
|
|
1300
|
+
for (const s of store.listSessions()) {
|
|
1301
|
+
if (s.status === "running") {
|
|
1302
|
+
updateStatus(s.id, "crashed", now());
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Requirement 2.3: simpan status terakhir Session `running` dalam budget
|
|
1309
|
+
* waktu, lalu hentikan seluruh server headless.
|
|
1310
|
+
*/
|
|
1311
|
+
async function shutdown(): Promise<void> {
|
|
1312
|
+
// Refresh skill tertunda tidak relevan lagi: server dihentikan, skill
|
|
1313
|
+
// terbaca saat server di-spawn berikutnya.
|
|
1314
|
+
for (const projectId of [...skillRefreshTimers.keys()]) cancelScheduledSkillRefresh(projectId);
|
|
1315
|
+
pendingSkillRefresh.clear();
|
|
1316
|
+
const running = store.listSessions().filter((s) => s.status === "running");
|
|
1317
|
+
const results = running.map((s) => store.insertStatusHistory(s.id, "running", now()));
|
|
1318
|
+
await raceWithTimeout(Promise.resolve(results), shutdownBudgetMs);
|
|
1319
|
+
await servers.stopAll();
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/** Balapan dengan timer budget; timer selalu dibersihkan setelah selesai. */
|
|
1323
|
+
async function raceWithTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
|
|
1324
|
+
let timer: unknown;
|
|
1325
|
+
const timeout: Promise<T> = new Promise((_resolve, reject) => {
|
|
1326
|
+
timer = setTimeoutFn(() => reject(new Error("shutdown budget exceeded")), ms);
|
|
1327
|
+
});
|
|
1328
|
+
try {
|
|
1329
|
+
return await Promise.race([p, timeout]);
|
|
1330
|
+
} finally {
|
|
1331
|
+
if (timer !== undefined) clearTimeoutFn(timer);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// Saat server keluar tak terduga, tandai Session project tsb `crashed`.
|
|
1336
|
+
servers.onServerExit((projectId) => {
|
|
1337
|
+
if (exitNotified.has(projectId)) return;
|
|
1338
|
+
exitNotified.add(projectId);
|
|
1339
|
+
const crashedAt = now();
|
|
1340
|
+
for (const s of store.listSessions()) {
|
|
1341
|
+
if (s.projectId === projectId && s.status === "running") {
|
|
1342
|
+
// Server mati di tengah turn: selamatkan parts yang sudah ter-stream.
|
|
1343
|
+
turns.finish(s.id);
|
|
1344
|
+
updateStatus(s.id, "crashed", crashedAt);
|
|
1345
|
+
unmapOcSessions(s.id);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
return {
|
|
1351
|
+
createSession,
|
|
1352
|
+
listSessions,
|
|
1353
|
+
getSession,
|
|
1354
|
+
stopSession,
|
|
1355
|
+
interruptSession,
|
|
1356
|
+
resumeSession,
|
|
1357
|
+
deleteSession,
|
|
1358
|
+
releaseProject,
|
|
1359
|
+
listModels,
|
|
1360
|
+
listAgents,
|
|
1361
|
+
listMcp,
|
|
1362
|
+
listSkills,
|
|
1363
|
+
refreshSkills,
|
|
1364
|
+
hasPendingSkillRefresh,
|
|
1365
|
+
setSessionModel,
|
|
1366
|
+
setSessionAgent,
|
|
1367
|
+
findFiles,
|
|
1368
|
+
sessionContext,
|
|
1369
|
+
sendFreeTextInput,
|
|
1370
|
+
resolvePrompt,
|
|
1371
|
+
reconcileOnStartup,
|
|
1372
|
+
shutdown,
|
|
1373
|
+
};
|
|
1374
|
+
}
|