@vibe-forge/adapter-opencode 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/AGENTS.md +13 -0
  2. package/LICENSE +21 -0
  3. package/__tests__/runtime-common.spec.ts +95 -0
  4. package/__tests__/runtime-config.spec.ts +124 -0
  5. package/__tests__/runtime-permissions.spec.ts +181 -0
  6. package/__tests__/runtime-test-helpers.ts +142 -0
  7. package/__tests__/session-runtime-config.spec.ts +156 -0
  8. package/__tests__/session-runtime-direct.spec.ts +141 -0
  9. package/__tests__/session-runtime-stream.spec.ts +181 -0
  10. package/package.json +59 -0
  11. package/src/AGENTS.md +38 -0
  12. package/src/adapter-config.ts +21 -0
  13. package/src/icon.ts +17 -0
  14. package/src/index.ts +11 -0
  15. package/src/models.ts +24 -0
  16. package/src/paths.ts +30 -0
  17. package/src/runtime/common/agent.ts +11 -0
  18. package/src/runtime/common/inline-config.ts +45 -0
  19. package/src/runtime/common/mcp.ts +47 -0
  20. package/src/runtime/common/model.ts +115 -0
  21. package/src/runtime/common/object-utils.ts +35 -0
  22. package/src/runtime/common/permission-node.ts +119 -0
  23. package/src/runtime/common/permissions.ts +73 -0
  24. package/src/runtime/common/prompt.ts +56 -0
  25. package/src/runtime/common/session-records.ts +61 -0
  26. package/src/runtime/common/tools.ts +129 -0
  27. package/src/runtime/common.ts +17 -0
  28. package/src/runtime/init.ts +55 -0
  29. package/src/runtime/session/child-env.ts +100 -0
  30. package/src/runtime/session/direct.ts +109 -0
  31. package/src/runtime/session/process.ts +55 -0
  32. package/src/runtime/session/shared.ts +72 -0
  33. package/src/runtime/session/skill-config.ts +84 -0
  34. package/src/runtime/session/stream.ts +198 -0
  35. package/src/runtime/session.ts +13 -0
  36. package/src/schema.ts +1 -0
@@ -0,0 +1,11 @@
1
+ import type { AdapterQueryOptions } from '@vibe-forge/core/adapter'
2
+
3
+ export const resolveOpenCodeAgent = (params: {
4
+ agent?: string
5
+ planAgent?: string | false
6
+ permissionMode?: AdapterQueryOptions['permissionMode']
7
+ }) => {
8
+ if (params.permissionMode !== 'plan') return params.agent
9
+ if (params.planAgent === false) return params.agent
10
+ return params.planAgent ?? params.agent ?? 'plan'
11
+ }
@@ -0,0 +1,45 @@
1
+ import type { Config } from '@vibe-forge/core'
2
+ import type { AdapterQueryOptions } from '@vibe-forge/core/adapter'
3
+
4
+ import { mapMcpServersToOpenCode } from './mcp'
5
+ import { deepMerge } from './object-utils'
6
+ import { mergePermissionNodes, normalizePermissionNode, rewritePermissionMode } from './permission-node'
7
+ import { buildToolPermissionConfig, mapPermissionModeToOpenCode } from './permissions'
8
+ import { buildToolConfig } from './tools'
9
+
10
+ export const buildInlineConfigContent = (params: {
11
+ adapterConfigContent?: Record<string, unknown>
12
+ envConfigContent?: Record<string, unknown>
13
+ permissionMode?: AdapterQueryOptions['permissionMode']
14
+ tools?: AdapterQueryOptions['tools']
15
+ mcpServers?: AdapterQueryOptions['mcpServers']
16
+ availableMcpServers?: Config['mcpServers']
17
+ systemPromptFile?: string
18
+ providerConfig?: Record<string, unknown>
19
+ }) => {
20
+ const mergedBaseConfig = deepMerge(params.envConfigContent ?? {}, params.adapterConfigContent ?? {})
21
+ const inheritedPermission = normalizePermissionNode(mergedBaseConfig.permission)
22
+ const hasToolFilter = (params.tools?.include?.length ?? 0) > 0 || (params.tools?.exclude?.length ?? 0) > 0
23
+ const basePermission = params.permissionMode === 'dontAsk' || params.permissionMode === 'bypassPermissions'
24
+ ? rewritePermissionMode(inheritedPermission, params.permissionMode)
25
+ : mergePermissionNodes(inheritedPermission, mapPermissionModeToOpenCode(params.permissionMode))
26
+ const permission = params.permissionMode == null && !hasToolFilter
27
+ ? undefined
28
+ : mergePermissionNodes(basePermission, buildToolPermissionConfig(params.tools, basePermission))
29
+ const tools = buildToolConfig(params.tools)
30
+ const mcp = mapMcpServersToOpenCode(params.availableMcpServers, params.mcpServers)
31
+ const inheritedInstructions = Array.isArray(mergedBaseConfig.instructions)
32
+ ? mergedBaseConfig.instructions.filter((value): value is string => typeof value === 'string' && value.trim() !== '')
33
+ : []
34
+ const instructions = params.systemPromptFile == null
35
+ ? inheritedInstructions
36
+ : [...inheritedInstructions, params.systemPromptFile]
37
+
38
+ return deepMerge(mergedBaseConfig, {
39
+ ...(instructions.length > 0 ? { instructions } : {}),
40
+ ...(permission != null ? { permission } : {}),
41
+ ...(tools ? { tools } : {}),
42
+ ...(mcp ? { mcp } : {}),
43
+ ...(params.providerConfig ? { provider: params.providerConfig } : {})
44
+ })
45
+ }
@@ -0,0 +1,47 @@
1
+ import type { Config } from '@vibe-forge/core'
2
+ import type { AdapterQueryOptions } from '@vibe-forge/core/adapter'
3
+
4
+ const filterMcpServers = (
5
+ mcpServers: Config['mcpServers'] | undefined,
6
+ selection: AdapterQueryOptions['mcpServers']
7
+ ) => {
8
+ if (mcpServers == null) return {}
9
+
10
+ const include = selection?.include != null && selection.include.length > 0
11
+ ? new Set(selection.include)
12
+ : undefined
13
+ const exclude = new Set(selection?.exclude ?? [])
14
+
15
+ return Object.fromEntries(
16
+ Object.entries(mcpServers).filter(([name]) => (include == null || include.has(name)) && !exclude.has(name))
17
+ )
18
+ }
19
+
20
+ export const mapMcpServersToOpenCode = (
21
+ mcpServers: Config['mcpServers'] | undefined,
22
+ selection: AdapterQueryOptions['mcpServers']
23
+ ) => {
24
+ const filtered = filterMcpServers(mcpServers, selection)
25
+ const result: Record<string, Record<string, unknown>> = {}
26
+
27
+ for (const [name, server] of Object.entries(filtered)) {
28
+ if ('url' in server) {
29
+ result[name] = {
30
+ type: 'remote',
31
+ url: server.url,
32
+ enabled: server.enabled ?? true,
33
+ ...(server.headers != null ? { headers: server.headers } : {})
34
+ }
35
+ continue
36
+ }
37
+
38
+ result[name] = {
39
+ type: 'local',
40
+ command: [server.command, ...server.args],
41
+ enabled: server.enabled ?? true,
42
+ ...(server.env != null ? { environment: server.env } : {})
43
+ }
44
+ }
45
+
46
+ return Object.keys(result).length > 0 ? result : undefined
47
+ }
@@ -0,0 +1,115 @@
1
+ import type { ModelServiceConfig } from '@vibe-forge/core'
2
+
3
+ import { asPlainRecord, normalizeStringRecord } from './object-utils'
4
+
5
+ const normalizePositiveInteger = (value: unknown): number | undefined => (
6
+ typeof value === 'number' && Number.isFinite(value) && value > 0
7
+ ? Math.floor(value)
8
+ : undefined
9
+ )
10
+
11
+ const appendQueryParams = (baseURL: string, queryParams: Record<string, string>) => {
12
+ if (Object.keys(queryParams).length === 0) return baseURL
13
+
14
+ const url = new URL(baseURL)
15
+ for (const [key, value] of Object.entries(queryParams)) {
16
+ url.searchParams.set(key, value)
17
+ }
18
+ return url.toString()
19
+ }
20
+
21
+ const normalizeProviderBaseURL = (baseURL: string, npmPackage: string) => {
22
+ if (npmPackage === '@ai-sdk/openai-compatible') return baseURL.replace(/\/chat\/completions\/?$/u, '')
23
+ if (npmPackage === '@ai-sdk/openai') return baseURL.replace(/\/responses\/?$/u, '')
24
+ return baseURL
25
+ }
26
+
27
+ const getProviderExtra = (service: ModelServiceConfig) => ({
28
+ ...asPlainRecord(asPlainRecord(service.extra)?.codex),
29
+ ...asPlainRecord(asPlainRecord(service.extra)?.opencode)
30
+ })
31
+
32
+ const inferProviderPackage = (service: ModelServiceConfig, providerExtra: Record<string, unknown>) => {
33
+ if (typeof providerExtra.npm === 'string' && providerExtra.npm.trim() !== '') return providerExtra.npm
34
+ const wireApi = typeof providerExtra.wireApi === 'string' ? providerExtra.wireApi : undefined
35
+ return wireApi === 'responses' || service.apiBaseUrl.includes('/responses')
36
+ ? '@ai-sdk/openai'
37
+ : '@ai-sdk/openai-compatible'
38
+ }
39
+
40
+ export const resolveOpenCodeModel = (
41
+ rawModel: string | undefined,
42
+ modelServices: Record<string, ModelServiceConfig>
43
+ ) => {
44
+ const normalized = rawModel?.trim()
45
+ if (normalized == null || normalized === '' || normalized.toLowerCase() === 'default') {
46
+ return { cliModel: undefined, providerConfig: undefined }
47
+ }
48
+
49
+ if (!normalized.includes(',')) {
50
+ return { cliModel: normalized, providerConfig: undefined }
51
+ }
52
+
53
+ const commaIndex = normalized.indexOf(',')
54
+ const serviceKey = normalized.slice(0, commaIndex).trim()
55
+ const modelId = normalized.slice(commaIndex + 1).trim()
56
+ const service = modelServices[serviceKey]
57
+
58
+ if (!service || modelId === '') {
59
+ return {
60
+ cliModel: serviceKey !== '' && modelId !== '' ? `${serviceKey}/${modelId}` : (modelId || normalized),
61
+ providerConfig: undefined
62
+ }
63
+ }
64
+
65
+ const providerExtra = getProviderExtra(service)
66
+ const providerId = typeof providerExtra.providerId === 'string' && providerExtra.providerId.trim() !== ''
67
+ ? providerExtra.providerId
68
+ : serviceKey
69
+ const npm = inferProviderPackage(service, providerExtra)
70
+ const baseURL = appendQueryParams(
71
+ normalizeProviderBaseURL(service.apiBaseUrl, npm),
72
+ normalizeStringRecord(providerExtra.queryParams)
73
+ )
74
+ const normalizedTimeoutMs = normalizePositiveInteger(service.timeoutMs)
75
+ const normalizedMaxOutputTokens = normalizePositiveInteger(service.maxOutputTokens)
76
+
77
+ return {
78
+ cliModel: `${providerId}/${modelId}`,
79
+ providerConfig: {
80
+ [providerId]: {
81
+ npm,
82
+ name: service.title?.trim() !== '' ? service.title : serviceKey,
83
+ options: {
84
+ apiKey: service.apiKey,
85
+ baseURL,
86
+ ...(normalizedTimeoutMs != null
87
+ ? {
88
+ timeout: normalizedTimeoutMs,
89
+ chunkTimeout: normalizedTimeoutMs
90
+ }
91
+ : {}),
92
+ ...(() => {
93
+ const headers = normalizeStringRecord(providerExtra.headers)
94
+ return Object.keys(headers).length > 0 ? { headers } : {}
95
+ })()
96
+ },
97
+ models: {
98
+ [modelId]: {
99
+ name: modelId,
100
+ ...(normalizedMaxOutputTokens != null
101
+ ? {
102
+ limit: {
103
+ output: normalizedMaxOutputTokens
104
+ },
105
+ options: {
106
+ maxOutputTokens: normalizedMaxOutputTokens
107
+ }
108
+ }
109
+ : {})
110
+ }
111
+ }
112
+ }
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,35 @@
1
+ export const asPlainRecord = (value: unknown): Record<string, unknown> | undefined => (
2
+ value != null && typeof value === 'object' && !Array.isArray(value)
3
+ ? value as Record<string, unknown>
4
+ : undefined
5
+ )
6
+
7
+ export const normalizeStringRecord = (value: unknown) => {
8
+ const result: Record<string, string> = {}
9
+ const record = asPlainRecord(value)
10
+ if (!record) return result
11
+
12
+ for (const [key, entry] of Object.entries(record)) {
13
+ if (typeof entry === 'string' && entry !== '') {
14
+ result[key] = entry
15
+ }
16
+ }
17
+
18
+ return result
19
+ }
20
+
21
+ export const deepMerge = (base: Record<string, unknown>, override: Record<string, unknown>) => {
22
+ const result: Record<string, unknown> = { ...base }
23
+
24
+ for (const [key, value] of Object.entries(override)) {
25
+ const baseRecord = asPlainRecord(result[key])
26
+ const valueRecord = asPlainRecord(value)
27
+ if (baseRecord && valueRecord) {
28
+ result[key] = deepMerge(baseRecord, valueRecord)
29
+ continue
30
+ }
31
+ result[key] = value
32
+ }
33
+
34
+ return result
35
+ }
@@ -0,0 +1,119 @@
1
+ import { asPlainRecord } from './object-utils'
2
+ import { LEGACY_TOOL_PERMISSION_ALIASES } from './tools'
3
+
4
+ export type PermissionValue = 'allow' | 'ask' | 'deny'
5
+ export type PermissionNode = PermissionValue | PermissionRecord
6
+ export interface PermissionRecord {
7
+ [key: string]: PermissionNode
8
+ }
9
+
10
+ export const isPermissionValue = (value: unknown): value is PermissionValue => (
11
+ value === 'allow' || value === 'ask' || value === 'deny'
12
+ )
13
+
14
+ export const normalizePermissionNode = (value: unknown): PermissionNode | undefined => {
15
+ if (isPermissionValue(value)) return value
16
+
17
+ const record = asPlainRecord(value)
18
+ if (!record) return undefined
19
+
20
+ const result: PermissionRecord = {}
21
+ for (const [key, entry] of Object.entries(record)) {
22
+ const normalized = normalizePermissionNode(entry)
23
+ if (normalized != null) result[key] = normalized
24
+ }
25
+
26
+ return Object.keys(result).length > 0 ? result : {}
27
+ }
28
+
29
+ const clonePermissionNode = (value: PermissionNode | undefined): PermissionNode | undefined => {
30
+ if (value == null || isPermissionValue(value)) return value
31
+ const result: PermissionRecord = {}
32
+ for (const [key, entry] of Object.entries(value)) {
33
+ const cloned = clonePermissionNode(entry)
34
+ if (cloned != null) result[key] = cloned
35
+ }
36
+ return result
37
+ }
38
+
39
+ const asPermissionRecord = (value: PermissionNode | undefined): PermissionRecord | undefined => (
40
+ value != null && !isPermissionValue(value) ? value : undefined
41
+ )
42
+
43
+ const transformPermissionNode = (
44
+ value: PermissionNode | undefined,
45
+ transform: (entry: PermissionValue) => PermissionValue
46
+ ): PermissionNode | undefined => {
47
+ if (value == null || isPermissionValue(value)) return value == null ? undefined : transform(value)
48
+
49
+ const result: PermissionRecord = {}
50
+ for (const [key, entry] of Object.entries(value)) {
51
+ const transformed = transformPermissionNode(entry, transform)
52
+ if (transformed != null) result[key] = transformed
53
+ }
54
+ return result
55
+ }
56
+
57
+ const applyPermissionLevel = (base: PermissionNode | undefined, level: PermissionValue): PermissionNode => {
58
+ if (base == null) return level
59
+ if (isPermissionValue(base)) {
60
+ if (level === 'deny') return 'deny'
61
+ if (level === 'allow') return base === 'ask' ? 'allow' : base
62
+ return base === 'allow' ? 'ask' : base
63
+ }
64
+
65
+ if (level === 'deny') return 'deny'
66
+ const record = asPermissionRecord(transformPermissionNode(base, (entry) => (
67
+ level === 'allow'
68
+ ? (entry === 'ask' ? 'allow' : entry)
69
+ : (entry === 'allow' ? 'ask' : entry)
70
+ ))) ?? {}
71
+ if (!('*' in record)) record['*'] = level
72
+ return record
73
+ }
74
+
75
+ export const mergePermissionNodes = (
76
+ base: PermissionNode | undefined,
77
+ override: PermissionNode | undefined
78
+ ): PermissionNode | undefined => {
79
+ if (override == null) return clonePermissionNode(base)
80
+ if (base == null) return clonePermissionNode(override)
81
+ if (isPermissionValue(base)) return isPermissionValue(override) ? override : mergePermissionNodes({ '*': base }, override)
82
+ if (isPermissionValue(override)) return applyPermissionLevel(base, override)
83
+
84
+ const result: PermissionRecord = { ...(clonePermissionNode(base) as PermissionRecord) }
85
+ for (const [key, value] of Object.entries(override)) {
86
+ result[key] = mergePermissionNodes(result[key], value) as PermissionNode
87
+ }
88
+ return result
89
+ }
90
+
91
+ export const rewritePermissionMode = (
92
+ value: PermissionNode | undefined,
93
+ mode: 'dontAsk' | 'bypassPermissions'
94
+ ): PermissionNode => {
95
+ const transformed = transformPermissionNode(value, (entry) => (
96
+ mode === 'bypassPermissions' || entry === 'ask' ? 'allow' : entry
97
+ ))
98
+
99
+ if (transformed == null || isPermissionValue(transformed)) return transformed ?? { '*': 'allow' }
100
+ if (!('*' in transformed)) transformed['*'] = 'allow'
101
+ return transformed
102
+ }
103
+
104
+ export const findPermissionEntry = (
105
+ permission: PermissionNode | undefined,
106
+ key: string
107
+ ): PermissionNode | undefined => {
108
+ const record = asPermissionRecord(permission)
109
+ if (!record) return undefined
110
+ if (key in record) return clonePermissionNode(record[key])
111
+
112
+ for (const [alias, normalized] of Object.entries(LEGACY_TOOL_PERMISSION_ALIASES)) {
113
+ if (normalized === key && alias in record) {
114
+ return clonePermissionNode(record[alias] as PermissionNode)
115
+ }
116
+ }
117
+
118
+ return undefined
119
+ }
@@ -0,0 +1,73 @@
1
+ import type { AdapterQueryOptions } from '@vibe-forge/core/adapter'
2
+
3
+ import {
4
+ findPermissionEntry,
5
+ isPermissionValue,
6
+ type PermissionNode,
7
+ type PermissionRecord,
8
+ type PermissionValue
9
+ } from './permission-node'
10
+ import { LEGACY_TOOL_PERMISSION_ALIASES } from './tools'
11
+
12
+ const OPENCODE_PERMISSION_KEYS = [
13
+ 'bash', 'edit', 'glob', 'grep', 'question', 'read', 'list', 'lsp', 'skill',
14
+ 'task', 'todoread', 'todowrite', 'webfetch', 'websearch', 'codesearch'
15
+ ]
16
+
17
+ export const mapPermissionModeToOpenCode = (
18
+ permissionMode: AdapterQueryOptions['permissionMode']
19
+ ): PermissionRecord | undefined => {
20
+ if (permissionMode == null) return undefined
21
+ if (permissionMode === 'dontAsk' || permissionMode === 'bypassPermissions') return { '*': 'allow' as const }
22
+ if (permissionMode === 'acceptEdits') return { '*': 'allow' as const, bash: 'ask', edit: 'allow', task: 'ask' }
23
+ if (permissionMode === 'plan') return { '*': 'allow' as const, bash: 'ask', edit: 'deny', task: 'deny' }
24
+ return { '*': 'allow' as const, bash: 'ask', edit: 'ask', task: 'ask' }
25
+ }
26
+
27
+ const normalizePermissionToolKey = (value: string) => {
28
+ const key = value.trim()
29
+ if (key === '') return undefined
30
+ return LEGACY_TOOL_PERMISSION_ALIASES[key] ?? key
31
+ }
32
+
33
+ const resolvePermissionValue = (
34
+ key: string,
35
+ basePermission: PermissionNode | undefined
36
+ ): PermissionValue => {
37
+ const explicit = findPermissionEntry(basePermission, key)
38
+ if (isPermissionValue(explicit)) return explicit
39
+
40
+ const wildcard = findPermissionEntry(basePermission, '*')
41
+ if (isPermissionValue(wildcard)) return wildcard
42
+
43
+ return isPermissionValue(basePermission) ? basePermission : 'allow'
44
+ }
45
+
46
+ export const buildToolPermissionConfig = (
47
+ tools: AdapterQueryOptions['tools'],
48
+ basePermission: PermissionNode | undefined = undefined
49
+ ) => {
50
+ const includes = new Set((tools?.include ?? []).map(normalizePermissionToolKey).filter(Boolean) as string[])
51
+ const excludes = new Set((tools?.exclude ?? []).map(normalizePermissionToolKey).filter(Boolean) as string[])
52
+ const allowAllTools = includes.delete('*')
53
+
54
+ if (includes.size === 0 && excludes.size === 0) return undefined
55
+
56
+ const result: PermissionRecord = {}
57
+ const explicitKeys = Object.keys(
58
+ isPermissionValue(basePermission) || basePermission == null ? {} : basePermission
59
+ ).map(normalizePermissionToolKey).filter((value): value is string => value != null && value !== '*')
60
+
61
+ if (includes.size > 0 && !allowAllTools) {
62
+ result['*'] = 'deny'
63
+ for (const key of new Set([...OPENCODE_PERMISSION_KEYS, ...explicitKeys])) {
64
+ if (!includes.has(key)) result[key] = 'deny'
65
+ }
66
+ for (const key of includes) {
67
+ result[key] = findPermissionEntry(basePermission, key) ?? resolvePermissionValue(key, basePermission)
68
+ }
69
+ }
70
+
71
+ for (const key of excludes) result[key] = 'deny'
72
+ return result
73
+ }
@@ -0,0 +1,56 @@
1
+ import { isAbsolute } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+
4
+ import type { AdapterMessageContent } from '@vibe-forge/core/adapter'
5
+
6
+ export const resolveLocalAttachmentPath = (value: string) => {
7
+ if (value.startsWith('file://')) {
8
+ try {
9
+ return fileURLToPath(value)
10
+ } catch {
11
+ return undefined
12
+ }
13
+ }
14
+
15
+ return isAbsolute(value) ? value : undefined
16
+ }
17
+
18
+ export const normalizeOpenCodePrompt = (content: AdapterMessageContent[]) => {
19
+ const promptParts: string[] = []
20
+ const files = new Set<string>()
21
+
22
+ for (const item of content) {
23
+ if (item.type === 'text') {
24
+ const text = item.text.trim()
25
+ if (text !== '') promptParts.push(text)
26
+ continue
27
+ }
28
+
29
+ if (item.type === 'image') {
30
+ const filePath = resolveLocalAttachmentPath(item.url)
31
+ if (filePath) files.add(filePath)
32
+ continue
33
+ }
34
+
35
+ if (item.type === 'tool_result') {
36
+ promptParts.push(String(item.content))
37
+ continue
38
+ }
39
+
40
+ if (item.type === 'tool_use') {
41
+ promptParts.push(`Tool request: ${item.name}`)
42
+ }
43
+ }
44
+
45
+ return {
46
+ prompt: promptParts.join('\n\n').trim() || (
47
+ files.size > 0 ? 'Please inspect the attached file(s).' : 'Continue.'
48
+ ),
49
+ files: Array.from(files)
50
+ }
51
+ }
52
+
53
+ export const buildOpenCodeSessionTitle = (
54
+ sessionId: string,
55
+ titlePrefix: string = 'Vibe Forge'
56
+ ) => `${titlePrefix}:${sessionId}`
@@ -0,0 +1,61 @@
1
+ import { asPlainRecord } from './object-utils'
2
+
3
+ const parseTimestamp = (value: unknown) => {
4
+ if (typeof value === 'number' && Number.isFinite(value)) return value
5
+
6
+ if (typeof value === 'string' && value.trim() !== '') {
7
+ const parsed = Date.parse(value)
8
+ return Number.isNaN(parsed) ? undefined : parsed
9
+ }
10
+
11
+ return undefined
12
+ }
13
+
14
+ export interface OpenCodeSessionRecord {
15
+ id: string
16
+ title?: string
17
+ updatedAt?: number
18
+ }
19
+
20
+ export const extractOpenCodeSessionRecords = (input: unknown): OpenCodeSessionRecord[] => {
21
+ const parsed = typeof input === 'string' ? JSON.parse(input) : input
22
+ const record = asPlainRecord(parsed)
23
+ const payload = Array.isArray(parsed)
24
+ ? parsed
25
+ : Array.isArray(record?.sessions)
26
+ ? record.sessions as unknown[]
27
+ : Array.isArray(record?.items)
28
+ ? record.items as unknown[]
29
+ : []
30
+
31
+ return payload.flatMap((entry) => {
32
+ const current = asPlainRecord(entry)
33
+ if (!current) return []
34
+
35
+ const id = [current.id, current.sessionId, current.sessionID]
36
+ .find((value): value is string => typeof value === 'string' && value.trim() !== '')
37
+ if (id == null) return []
38
+
39
+ return [{
40
+ id,
41
+ title: typeof current.title === 'string' && current.title.trim() !== '' ? current.title : undefined,
42
+ updatedAt: parseTimestamp(
43
+ current.updatedAt ??
44
+ current.updated_at ??
45
+ current.modifiedAt ??
46
+ current.modified_at ??
47
+ current.createdAt ??
48
+ current.created_at
49
+ )
50
+ }]
51
+ })
52
+ }
53
+
54
+ export const selectOpenCodeSessionByTitle = (
55
+ sessions: OpenCodeSessionRecord[],
56
+ title: string
57
+ ) => (
58
+ sessions
59
+ .filter(session => session.title === title)
60
+ .sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0))[0]
61
+ )