opencode-async-agent 1.0.0 → 1.0.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.
@@ -1,200 +0,0 @@
1
- /**
2
- * async-agent
3
- * Unified delegation system for OpenCode
4
- *
5
- * Based on oh-my-opencode by @code-yeongyu (MIT License)
6
- * https://github.com/code-yeongyu/oh-my-opencode
7
- */
8
-
9
- import type { Plugin } from "@opencode-ai/plugin"
10
- import type { Event } from "@opencode-ai/sdk"
11
- import type { OpencodeClient } from "./types"
12
- import { createLogger } from "./utils"
13
- import { DelegationManager } from "./manager"
14
- import {
15
- createDelegate,
16
- createDelegationRead,
17
- createDelegationList,
18
- createDelegationCancel,
19
- createDelegationResume,
20
- } from "./tools"
21
- import { DELEGATION_RULES, formatDelegationContext, readBgAgentsConfig } from "./rules"
22
-
23
- // Slash command name — user types /delegation in chat
24
- const delegationCommand = "delegation"
25
-
26
- export const AsyncAgentPlugin: Plugin = async (ctx) => {
27
- const { client } = ctx
28
-
29
- const log = createLogger(client as OpencodeClient)
30
- const manager = new DelegationManager(client as OpencodeClient, log)
31
-
32
- await manager.debugLog("AsyncAgentPlugin initialized")
33
-
34
- return {
35
- // Handle /delegation slash command execution
36
- "command.execute.before": async (input: { command: string; sessionID: string }) => {
37
- if (input.command !== delegationCommand) return
38
-
39
- const typedClient = client as OpencodeClient
40
-
41
- // Query child sessions of current session from OpenCode API (survives reboots)
42
- let childSessions: any[] = []
43
- try {
44
- const result = await typedClient.session.children({
45
- path: { id: input.sessionID },
46
- })
47
- childSessions = (result.data ?? []) as any[]
48
- } catch {
49
- // Fallback to in-memory only if API fails
50
- }
51
-
52
- // Filter for delegation sessions — created with title "Delegation: <agent>"
53
- const delegationSessions = childSessions.filter(
54
- (s: any) => s.title?.startsWith("Delegation:"),
55
- )
56
-
57
- // Get in-memory state for extra metadata (status, duration, agent)
58
- const inMemory = manager.listAllDelegations()
59
- const inMemoryMap = new Map(inMemory.map((d) => [d.id, d]))
60
-
61
- let message: string
62
- if (delegationSessions.length === 0 && inMemory.length === 0) {
63
- message = "No delegations found for this session."
64
- } else {
65
- const lines: string[] = []
66
- const seen = new Set<string>()
67
- const entries: {
68
- id: string
69
- title: string
70
- agent: string
71
- status: string
72
- duration: string
73
- started: string
74
- }[] = []
75
-
76
- // Persisted child sessions from API — survive reboots
77
- for (const s of delegationSessions) {
78
- seen.add(s.id)
79
- const mem = inMemoryMap.get(s.id)
80
- // Extract agent name from title "Delegation: <agent>"
81
- const agent = mem?.agent ?? s.title?.replace("Delegation: ", "") ?? "unknown"
82
- const status = mem?.status?.toUpperCase() ?? "PERSISTED"
83
- const duration = mem?.duration ?? "—"
84
- // time.created is already in ms — do NOT multiply by 1000
85
- const created = s.time?.created
86
- ? new Date(s.time.created).toISOString()
87
- : mem?.startedAt?.toISOString() ?? "—"
88
-
89
- entries.push({
90
- id: s.id,
91
- title: mem?.title ?? s.title ?? "—",
92
- agent,
93
- status,
94
- duration,
95
- started: created,
96
- })
97
- }
98
-
99
- // In-memory delegations not in API yet (just launched, or API miss)
100
- for (const d of inMemory) {
101
- if (seen.has(d.id)) continue
102
- // Only include delegations belonging to this parent session
103
- entries.push({
104
- id: d.id,
105
- title: d.title ?? d.description?.slice(0, 60) ?? "—",
106
- agent: d.agent ?? "unknown",
107
- status: d.status?.toUpperCase() ?? "UNKNOWN",
108
- duration: d.duration ?? "—",
109
- started: d.startedAt?.toISOString() ?? "—",
110
- })
111
- }
112
-
113
- lines.push(`## Delegations (${entries.length})\n`)
114
-
115
- for (const e of entries) {
116
- lines.push(`**[${e.id}]** ${e.title}`)
117
- lines.push(` Status: ${e.status} | Agent: ${e.agent} | Duration: ${e.duration}`)
118
- lines.push(` Started: ${e.started}`)
119
- lines.push(` \`opencode -s ${e.id}\``)
120
- lines.push("")
121
- }
122
-
123
- message = lines.join("\n")
124
- }
125
-
126
- // Send output to the user's session
127
- await typedClient.session.prompt({
128
- path: { id: input.sessionID },
129
- body: {
130
- noReply: true,
131
- parts: [{ type: "text", text: message }],
132
- },
133
- })
134
-
135
- throw new Error("Command handled by async-agent plugin")
136
- },
137
-
138
- tool: {
139
- delegate: createDelegate(manager),
140
- delegation_read: createDelegationRead(manager),
141
- delegation_list: createDelegationList(manager),
142
- delegation_cancel: createDelegationCancel(manager),
143
- delegation_resume: createDelegationResume(manager),
144
- },
145
-
146
- // Register /delegation slash command
147
- config: async (input: any) => {
148
- if (!input.command) input.command = {}
149
- input.command[delegationCommand] = {
150
- template: "Show all background delegation sessions with their status, IDs, agents, and metadata.",
151
- description: "List all background delegations",
152
- }
153
- },
154
-
155
- // Inject delegation rules + async-agent.md config into system prompt
156
- "experimental.chat.system.transform": async (
157
- _input: { sessionID?: string; model: any },
158
- output: { system: string[] },
159
- ): Promise<void> => {
160
- output.system.push(DELEGATION_RULES)
161
-
162
- // Read user's model config from ~/.config/opencode/async-agent.md
163
- const bgConfig = await readBgAgentsConfig()
164
- if (bgConfig.trim()) {
165
- output.system.push(`<async-agent-config>\n${bgConfig}\n</async-agent-config>`)
166
- }
167
- },
168
-
169
- // Inject active delegation context during session compaction
170
- "experimental.session.compacting": async (
171
- _input: { sessionID: string },
172
- output: { context: string[] },
173
- ): Promise<void> => {
174
- const running = manager.getRunningDelegations().map((d) => ({
175
- id: d.id,
176
- agent: d.agent,
177
- status: d.status,
178
- startedAt: d.startedAt,
179
- }))
180
-
181
- // Only inject if there are active delegations
182
- if (running.length > 0) {
183
- output.context.push(formatDelegationContext(running, []))
184
- }
185
- },
186
-
187
- event: async (input: { event: Event }): Promise<void> => {
188
- const { event } = input
189
- if (event.type === "session.idle") {
190
- const sessionID = (event.properties as any)?.sessionID
191
- const delegation = manager.findBySession(sessionID)
192
- if (delegation) {
193
- await manager.handleSessionIdle(sessionID)
194
- }
195
- }
196
- },
197
- }
198
- }
199
-
200
- export default AsyncAgentPlugin
@@ -1,115 +0,0 @@
1
- // System prompt rules injected to teach the agent how to use delegation tools
2
- export const DELEGATION_RULES = `<system-reminder>
3
- <delegation-system>
4
-
5
- ## Async Delegation
6
-
7
- You have tools for parallel background work:
8
- - \`delegate(prompt, agent)\` - Launch task, returns ID immediately
9
- - \`delegate(prompt, agent, model)\` - Launch task with specific model override
10
- - \`delegation_read(id)\` - Retrieve completed result
11
- - \`delegation_list()\` - List delegations (use sparingly)
12
- - \`delegation_cancel(id|all)\` - Cancel running task(s)
13
- - \`delegation_resume(id, prompt?)\` - Continue cancelled task (same session)
14
-
15
- ## How It Works
16
-
17
- 1. Call \`delegate()\` - Get task ID immediately, continue working
18
- 2. Receive \`<system-reminder>\` notification when complete
19
- 3. Call \`delegation_read(id)\` to get the actual result
20
-
21
- ## Model Override
22
-
23
- The \`delegate\` tool accepts an optional \`model\` parameter in "provider/model" format.
24
- Example: \`delegate(prompt, agent, model="minimax/MiniMax-M2.5")\`
25
- If not specified, the agent's default model is used.
26
-
27
- ## Critical Constraints
28
-
29
- **NEVER poll \`delegation_list\` to check completion.**
30
- You WILL be notified via \`<system-reminder>\`. Polling wastes tokens.
31
-
32
- **NEVER wait idle.** Always have productive work while delegations run.
33
-
34
- **Cancelled tasks can be resumed** with \`delegation_resume()\` - same session, full context.
35
-
36
- </delegation-system>
37
- </system-reminder>`
38
-
39
- // Read user's async-agent(s).md config file — contains model preferences for delegation
40
- // Tries both async-agents.md and async-agent.md, returns first found or empty string
41
- export async function readBgAgentsConfig(): Promise<string> {
42
- const { homedir } = await import("os")
43
- const { readFile } = await import("fs/promises")
44
- const { join } = await import("path")
45
-
46
- const configDir = join(homedir(), ".config", "opencode")
47
- for (const name of ["async-agents.md", "async-agent.md"]) {
48
- try {
49
- return await readFile(join(configDir, name), "utf-8")
50
- } catch {}
51
- }
52
- return ""
53
- }
54
-
55
- // Context injected during compaction so the agent remembers active delegations
56
- interface DelegationForContext {
57
- id: string
58
- agent?: string
59
- title?: string
60
- description?: string
61
- status: string
62
- startedAt?: Date
63
- duration?: string
64
- }
65
-
66
- export function formatDelegationContext(
67
- running: DelegationForContext[],
68
- completed: DelegationForContext[],
69
- ): string {
70
- const sections: string[] = ["<delegation-context>"]
71
-
72
- if (running.length > 0) {
73
- sections.push("## Running Delegations")
74
- sections.push("")
75
- for (const d of running) {
76
- sections.push(`### \`${d.id}\`${d.agent ? ` (${d.agent})` : ""}`)
77
- if (d.startedAt) {
78
- sections.push(`**Started:** ${d.startedAt.toISOString()}`)
79
- }
80
- sections.push("")
81
- }
82
- sections.push("> **Note:** You WILL be notified via \`<system-reminder>\` when delegations complete.",
83
- )
84
- sections.push("> Do NOT poll \`delegation_list\` - continue productive work.")
85
- sections.push("")
86
- }
87
-
88
- if (completed.length > 0) {
89
- sections.push("## Recent Completed Delegations")
90
- sections.push("")
91
- for (const d of completed) {
92
- const statusEmoji =
93
- d.status === "completed"
94
- ? "✅"
95
- : d.status === "error"
96
- ? "❌"
97
- : d.status === "timeout"
98
- ? "⏱️"
99
- : "🚫"
100
- sections.push(`### ${statusEmoji} \`${d.id}\``)
101
- sections.push(`**Status:** ${d.status}`)
102
- if (d.duration) sections.push(`**Duration:** ${d.duration}`)
103
- sections.push("")
104
- }
105
- sections.push("> Use \`delegation_list()\` to see all delegations.")
106
- sections.push("")
107
- }
108
-
109
- sections.push("## Retrieval")
110
- sections.push('Use \`delegation_read("id")\` to access results.')
111
- sections.push("Use \`delegation_read(id, mode=\"full\")\` for full conversation.")
112
- sections.push("</delegation-context>")
113
-
114
- return sections.join("\n")
115
- }
@@ -1,230 +0,0 @@
1
- import { type ToolContext, tool } from "@opencode-ai/plugin"
2
- import type { ReadDelegationArgs } from "./types"
3
- import type { DelegationManager } from "./manager"
4
-
5
- // ---- Arg interfaces ----
6
-
7
- interface DelegateArgs {
8
- prompt: string
9
- agent: string
10
- model?: string
11
- }
12
-
13
- interface CancelArgs {
14
- id?: string
15
- all?: boolean
16
- }
17
-
18
- interface ResumeArgs {
19
- id: string
20
- prompt?: string
21
- }
22
-
23
- // ---- Tool creators ----
24
-
25
- export function createDelegate(manager: DelegationManager): ReturnType<typeof tool> {
26
- return tool({
27
- description: `Delegate a task to an agent. Returns immediately with the session ID.
28
-
29
- Use this for:
30
- - Research tasks (will be auto-saved)
31
- - Parallel work that can run in background
32
- - Any task where you want persistent, retrievable output
33
-
34
- On completion, a notification will arrive with the session ID, status, duration.
35
- Use \`delegation_read\` with the session ID to retrieve the result.`,
36
- args: {
37
- prompt: tool.schema
38
- .string()
39
- .describe("The full detailed prompt for the agent. Must be in English."),
40
- agent: tool.schema
41
- .string()
42
- .describe(
43
- 'Agent to delegate to: "explore" (codebase search), "researcher" (external research), etc.',
44
- ),
45
- model: tool.schema
46
- .string()
47
- .optional()
48
- .describe(
49
- 'Override model for this delegation. Format: "provider/model" (e.g. "minimax/MiniMax-M2.5"). If not set, uses the agent default.',
50
- ),
51
- },
52
- async execute(args: DelegateArgs, toolCtx: ToolContext): Promise<string> {
53
- if (!toolCtx?.sessionID) {
54
- return "❌ delegate requires sessionID. This is a system error."
55
- }
56
- if (!toolCtx?.messageID) {
57
- return "❌ delegate requires messageID. This is a system error."
58
- }
59
-
60
- // Block sub-agents from using delegation
61
- if (manager.findBySession(toolCtx.sessionID)) {
62
- return "❌ Sub-agents cannot delegate tasks. Only the main agent can use delegation tools."
63
- }
64
-
65
- try {
66
- const delegation = await manager.delegate({
67
- parentSessionID: toolCtx.sessionID,
68
- parentMessageID: toolCtx.messageID,
69
- parentAgent: toolCtx.agent,
70
- prompt: args.prompt,
71
- agent: args.agent,
72
- model: args.model,
73
- })
74
-
75
- const pendingCount = manager.getPendingCount(toolCtx.sessionID)
76
-
77
- let response = `Delegation started: ${delegation.id}\nAgent: ${args.agent}`
78
- if (pendingCount > 1) {
79
- response += `\n\n${pendingCount} delegations now active.`
80
- }
81
- response += `\n\nYou WILL be notified via <system-reminder> when complete. Do NOT poll delegation_list().`
82
-
83
- return response
84
- } catch (error) {
85
- return `❌ Delegation failed:\n\n${error instanceof Error ? error.message : "Unknown error"}`
86
- }
87
- },
88
- })
89
- }
90
-
91
- export function createDelegationRead(manager: DelegationManager): ReturnType<typeof tool> {
92
- return tool({
93
- description: `Read the output of a delegation by its ID.
94
-
95
- Modes:
96
- - simple (default): Returns just the final result
97
- - full: Returns all messages in the session with timestamps
98
-
99
- Use filters to get specific parts of the conversation.`,
100
- args: {
101
- id: tool.schema.string().describe("The delegation session ID"),
102
- mode: tool.schema
103
- .enum(["simple", "full"])
104
- .optional()
105
- .describe("Output mode: 'simple' for result only, 'full' for all messages"),
106
- include_thinking: tool.schema
107
- .boolean()
108
- .optional()
109
- .describe("Include thinking/reasoning blocks in full mode"),
110
- include_tools: tool.schema
111
- .boolean()
112
- .optional()
113
- .describe("Include tool results in full mode"),
114
- since_message_id: tool.schema
115
- .string()
116
- .optional()
117
- .describe("Return only messages after this message ID (full mode only)"),
118
- limit: tool.schema
119
- .number()
120
- .optional()
121
- .describe("Max messages to return, capped at 100 (full mode only)"),
122
- },
123
- async execute(args: ReadDelegationArgs, toolCtx: ToolContext): Promise<string> {
124
- if (!toolCtx?.sessionID) {
125
- return "❌ delegation_read requires sessionID. This is a system error."
126
- }
127
-
128
- try {
129
- return await manager.readDelegation(args)
130
- } catch (error) {
131
- return `❌ Error reading delegation: ${error instanceof Error ? error.message : "Unknown error"}`
132
- }
133
- },
134
- })
135
- }
136
-
137
- export function createDelegationList(manager: DelegationManager): ReturnType<typeof tool> {
138
- return tool({
139
- description: `List all delegations for the current session.
140
- Shows running, completed, cancelled, and error tasks with metadata.`,
141
- args: {},
142
- async execute(_args: Record<string, never>, toolCtx: ToolContext): Promise<string> {
143
- if (!toolCtx?.sessionID) {
144
- return "❌ delegation_list requires sessionID. This is a system error."
145
- }
146
-
147
- const delegations = await manager.listDelegations(toolCtx.sessionID)
148
-
149
- if (delegations.length === 0) {
150
- return "No delegations found for this session."
151
- }
152
-
153
- const lines = delegations.map((d) => {
154
- const titlePart = d.title ? ` | ${d.title}` : ""
155
- const durationPart = d.duration ? ` (${d.duration})` : ""
156
- const descPart = d.description ? `\n → ${d.description.slice(0, 100)}${d.description.length > 100 ? "..." : ""}` : ""
157
- return `- **${d.id}**${titlePart} [${d.status}]${durationPart}${descPart}`
158
- })
159
-
160
- return `## Delegations\n\n${lines.join("\n")}`
161
- },
162
- })
163
- }
164
-
165
- export function createDelegationCancel(manager: DelegationManager): ReturnType<typeof tool> {
166
- return tool({
167
- description: `Cancel a running delegation by ID, or cancel all running delegations.
168
-
169
- Cancelled tasks can be resumed later with delegation_resume().`,
170
- args: {
171
- id: tool.schema.string().optional().describe("Task ID to cancel"),
172
- all: tool.schema.boolean().optional().describe("Cancel ALL running delegations"),
173
- },
174
- async execute(args: CancelArgs, toolCtx: ToolContext): Promise<string> {
175
- if (!toolCtx?.sessionID) {
176
- return "❌ delegation_cancel requires sessionID. This is a system error."
177
- }
178
-
179
- try {
180
- if (args.all) {
181
- const cancelled = await manager.cancelAll(toolCtx.sessionID)
182
- if (cancelled.length === 0) {
183
- return "No running delegations to cancel."
184
- }
185
- return `Cancelled ${cancelled.length} delegation(s):\n${cancelled.map(id => `- ${id}`).join("\n")}`
186
- }
187
-
188
- if (!args.id) {
189
- return "❌ Must provide either 'id' or 'all=true'"
190
- }
191
-
192
- const success = await manager.cancel(args.id)
193
- if (!success) {
194
- return `❌ Could not cancel "${args.id}". Task may not exist or is not running.`
195
- }
196
-
197
- return `✅ Cancelled delegation: ${args.id}\n\nYou can resume it later with delegation_resume(id="${args.id}")`
198
- } catch (error) {
199
- return `❌ Error cancelling: ${error instanceof Error ? error.message : "Unknown error"}`
200
- }
201
- },
202
- })
203
- }
204
-
205
- export function createDelegationResume(manager: DelegationManager): ReturnType<typeof tool> {
206
- return tool({
207
- description: `Resume a cancelled or errored delegation by sending a new prompt to the same session.
208
-
209
- The agent will have access to the previous conversation context.`,
210
- args: {
211
- id: tool.schema.string().describe("Task ID to resume"),
212
- prompt: tool.schema
213
- .string()
214
- .optional()
215
- .describe("Optional prompt to send (default: 'Continue from where you left off.')"),
216
- },
217
- async execute(args: ResumeArgs, toolCtx: ToolContext): Promise<string> {
218
- if (!toolCtx?.sessionID) {
219
- return "❌ delegation_resume requires sessionID. This is a system error."
220
- }
221
-
222
- try {
223
- const delegation = await manager.resume(args.id, args.prompt)
224
- return `✅ Resumed delegation: ${delegation.id}\nAgent: ${delegation.agent}\nStatus: ${delegation.status}`
225
- } catch (error) {
226
- return `❌ Error resuming: ${error instanceof Error ? error.message : "Unknown error"}`
227
- }
228
- },
229
- })
230
- }
@@ -1,80 +0,0 @@
1
- import type { Message, Part } from "@opencode-ai/sdk"
2
- import type { createOpencodeClient } from "@opencode-ai/sdk"
3
-
4
- // OpenCode client instance type
5
- export type OpencodeClient = ReturnType<typeof createOpencodeClient>
6
-
7
- // Logger returned by createLogger
8
- export type Logger = {
9
- debug: (msg: string) => void
10
- info: (msg: string) => void
11
- warn: (msg: string) => void
12
- error: (msg: string) => void
13
- }
14
-
15
- // 15 minute max run time per delegation
16
- export const MAX_RUN_TIME_MS = 15 * 60 * 1000
17
-
18
- export interface SessionMessageItem {
19
- info: Message
20
- parts: Part[]
21
- }
22
-
23
- export interface AssistantSessionMessageItem {
24
- info: Message & { role: "assistant" }
25
- parts: Part[]
26
- }
27
-
28
- export interface DelegationProgress {
29
- toolCalls: number
30
- lastUpdate: Date
31
- lastMessage?: string
32
- lastMessageAt?: Date
33
- }
34
-
35
- export interface Delegation {
36
- id: string // OpenCode session ID (same as sessionID — used for delegation_read, opencode -s, etc.)
37
- sessionID: string // Same as id — kept for clarity in API calls
38
- parentSessionID: string
39
- parentMessageID: string
40
- parentAgent: string
41
- prompt: string
42
- agent: string
43
- model?: string // Full "provider/model" string (e.g. "minimax/MiniMax-M2.5")
44
- status: "running" | "completed" | "error" | "cancelled" | "timeout"
45
- startedAt: Date
46
- completedAt?: Date
47
- duration?: string
48
- progress: DelegationProgress
49
- error?: string
50
- title?: string
51
- description?: string
52
- }
53
-
54
- export interface DelegateInput {
55
- parentSessionID: string
56
- parentMessageID: string
57
- parentAgent: string
58
- prompt: string
59
- agent: string
60
- model?: string // Full "provider/model" string — split via parseModel() before passing to session.prompt()
61
- }
62
-
63
- export interface DelegationListItem {
64
- id: string
65
- status: string
66
- title?: string
67
- description?: string
68
- agent?: string
69
- duration?: string
70
- startedAt?: Date
71
- }
72
-
73
- export interface ReadDelegationArgs {
74
- id: string
75
- mode?: "simple" | "full"
76
- include_thinking?: boolean
77
- include_tools?: boolean
78
- since_message_id?: string
79
- limit?: number
80
- }
@@ -1,51 +0,0 @@
1
- import type { OpencodeClient, Logger } from "./types"
2
-
3
- // Structured logger → OpenCode log API
4
- // Catches errors silently to avoid disrupting tool execution
5
- export function createLogger(client: OpencodeClient): Logger {
6
- const log = (level: "debug" | "info" | "warn" | "error", message: string) =>
7
- client.app.log({ body: { service: "async-agent", level, message } }).catch(() => {})
8
- return {
9
- debug: (msg: string) => log("debug", msg),
10
- info: (msg: string) => log("info", msg),
11
- warn: (msg: string) => log("warn", msg),
12
- error: (msg: string) => log("error", msg),
13
- }
14
- }
15
-
16
- // Shows toast in OpenCode TUI (top-right notification)
17
- // Casts client to any + optional chaining = graceful no-op if unavailable
18
- export function showToast(
19
- client: OpencodeClient,
20
- title: string,
21
- message: string,
22
- variant: "info" | "success" | "error" = "info",
23
- duration = 3000,
24
- ) {
25
- const tuiClient = client as any
26
- if (!tuiClient.tui?.showToast) return
27
- tuiClient.tui.showToast({
28
- body: { title, message, variant, duration },
29
- }).catch(() => {})
30
- }
31
-
32
- // Format ms duration into human-readable string (e.g. "2m 30s")
33
- export function formatDuration(startedAt: Date, completedAt?: Date): string {
34
- const end = completedAt || new Date()
35
- const diffMs = end.getTime() - startedAt.getTime()
36
- const diffSec = Math.floor(diffMs / 1000)
37
-
38
- if (diffSec < 60) {
39
- return `${diffSec}s`
40
- }
41
-
42
- const diffMin = Math.floor(diffSec / 60)
43
- if (diffMin < 60) {
44
- const secs = diffSec % 60
45
- return secs > 0 ? `${diffMin}m ${secs}s` : `${diffMin}m`
46
- }
47
-
48
- const diffHour = Math.floor(diffMin / 60)
49
- const mins = diffMin % 60
50
- return mins > 0 ? `${diffHour}h ${mins}m` : `${diffHour}h`
51
- }