opencode-ultracode 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.
@@ -0,0 +1,394 @@
1
+ // opencode-ultracode — server plugin.
2
+ //
3
+ // Registers the `workflow` tool (model-facing orchestration), keyword triggers
4
+ // ("run a workflow", "ultracode", ...), a system-prompt nudge so the model
5
+ // recommends workflows for large tasks, and live usage tracking for sub-agent
6
+ // child sessions.
7
+
8
+ import { tool, type Hooks, type PluginInput } from "@opencode-ai/plugin"
9
+ import { join } from "node:path"
10
+ import { fileURLToPath } from "node:url"
11
+ import { existsSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"
12
+ import { generateRunId, loadPriorRun, RunEngine } from "../runtime/engine.ts"
13
+ import { parseScript } from "../runtime/script.ts"
14
+ import { type AgentState, type RunState, controlPath, runsRoot } from "../shared/state.ts"
15
+
16
+ const KEYWORD_RE =
17
+ /\b(ultracode|run\s+a\s+workflow|start\s+(?:a\s+)?workflow|use\s+(?:a\s+)?workflow)\b/iu
18
+
19
+ const KEYWORD_DIRECTIVE =
20
+ "[workflow requested] The user explicitly asked for workflow orchestration. Immediately call the `workflow` tool: author a script (must start with `export const meta = {...}`) that decomposes the request into phases and parallel agents, using agent()/parallel()/pipeline()/phase()/log(). Keep the plan proportional to the task."
21
+
22
+ const SYSTEM_GUIDANCE = `## Workflow orchestration
23
+ You have a \`workflow\` tool that fans a task out across many parallel sub-agents (50-100 in large runs) with phases, structured outputs, and a live progress view (/workflows).
24
+ Use it when:
25
+ - the user explicitly asks ("run a workflow", "ultracode", or the same in another language — the model decides), or
26
+ - the task is too large for one pass: it spans many files/modules, decomposes into parallel workstreams (audit, review, migration, research), or benefits from independent adversarial verification.
27
+ When the task seems large but the user did not ask: recommend a workflow in one or two sentences (scale + rough shape: phases and agent count) and wait for the go-ahead before calling the tool.
28
+ Do NOT use workflows for trivial or single-file tasks.
29
+ For script format (meta block, primitives, patterns), load the \`workflow-authoring\` skill (bundled with this plugin) before writing a script.`
30
+
31
+ const TOOL_DESCRIPTION = `Run a multi-agent workflow: parallel sub-agents over phases with structured outputs, for tasks too large for one pass.
32
+ Args: one of \`script\` (inline JS workflow, must start with \`export const meta = { name, description, phases? }\`), \`scriptPath\` (file), or \`name\` (saved workflow in .opencode/workflows/). Optional \`args\` passed to the script.
33
+ The run starts in the background after the user approves the plan; a result turn is delivered when it finishes. The user can watch progress in /workflows (phases, per-agent model/tokens/time, stop/pause).
34
+ \`resumeRunId\`: restart a run that was stopped or whose engine died (opencode exited while it ran). Completed agents replay from the journal; the rest run again. The user can also do this with \`p\` in /workflows.
35
+ Use for: audits, multi-file migrations, code review across many files, research sweeps, anything parallelizable or needing independent verification.
36
+ Authoring guide: the workflow-authoring skill (bundled with this plugin; load it first) documents agent()/parallel()/pipeline()/phase()/log(), schema-validated structured output, and quality patterns.`
37
+
38
+ // Skills shipped with the plugin (skills/<name>/SKILL.md next to src/). Registered
39
+ // through the `config` hook via `skills.paths`, so the model gets the
40
+ // workflow-authoring skill in every project without the user copying files
41
+ // into .opencode/skill or ~/.config/opencode/skill.
42
+ const BUNDLED_SKILLS_DIR = fileURLToPath(new URL("../../skills", import.meta.url))
43
+
44
+ type Client = NonNullable<PluginInput["client"]>
45
+
46
+ function modelLabel(providerID: string | undefined, id: string | undefined): string | undefined {
47
+ if (!providerID || !id) return undefined
48
+ return `${providerID}/${id}`
49
+ }
50
+
51
+ export default async (input: PluginInput): Promise<Hooks> => {
52
+ const active = new Map<string, RunEngine>()
53
+ const sessionModel = new Map<string, string>()
54
+ const childSessionRun = new Map<string, string>()
55
+ const childSessions = new Set<string>()
56
+
57
+ const log = (level: "debug" | "info" | "warn" | "error", message: string, meta?: Record<string, unknown>) => {
58
+ try {
59
+ input.client.app.log({ body: { level, service: "workflow", message, ...meta } }).catch(() => {})
60
+ } catch {}
61
+ }
62
+
63
+ // Fetched lazily (first workflow call), never during plugin init:
64
+ // awaiting a client call at init deadlocks against the server that is
65
+ // still bootstrapping (it waits for plugins before serving HTTP).
66
+ let modelsPromise: Promise<Set<string>> | undefined
67
+ const fetchModels = (): Promise<Set<string>> => {
68
+ modelsPromise ??= (async () => {
69
+ const out = new Set<string>()
70
+ try {
71
+ const res: any = await Promise.race([
72
+ input.client.config.providers(),
73
+ new Promise((_, rej) => setTimeout(() => rej(new Error("providers timeout")), 5000)),
74
+ ])
75
+ const data = res?.providers ?? res?.data?.providers ?? res ?? []
76
+ for (const p of data) for (const m of Object.keys(p?.models ?? {})) out.add(`${p.id}/${m}`)
77
+ } catch (e) {
78
+ log("warn", `could not list models: ${errMsg(e)}`)
79
+ }
80
+ return out
81
+ })()
82
+ return modelsPromise
83
+ }
84
+
85
+ const projectRootOf = (worktree: string, directory: string): string =>
86
+ existsSync(worktree) ? worktree : directory
87
+ const opencodeDirOf = (worktree: string, directory: string): string =>
88
+ join(projectRootOf(worktree, directory), ".opencode")
89
+
90
+ const preview = (args: { script?: string; scriptPath?: string; name?: string }): {
91
+ name: string
92
+ description: string
93
+ phases: string[]
94
+ } => {
95
+ let raw = args.script
96
+ if (!raw && args.scriptPath) {
97
+ raw = readFileSync(args.scriptPath, "utf8")
98
+ }
99
+ if (!raw && args.name) {
100
+ const bases = [join(input.directory, ".opencode", "workflows"), join(input.worktree, ".opencode", "workflows")]
101
+ for (const base of bases) {
102
+ for (const ext of [".js", ""]) {
103
+ const p = join(base, `${safeName(args.name)}${ext}`)
104
+ if (existsSync(p)) raw = readFileSync(p, "utf8")
105
+ }
106
+ }
107
+ }
108
+ if (!raw) throw new Error("no workflow script provided")
109
+ const parsed = parseScript(raw)
110
+ return {
111
+ name: parsed.meta.name,
112
+ description: parsed.meta.description,
113
+ phases: (parsed.meta.phases ?? []).map((p) => p.title),
114
+ }
115
+ }
116
+
117
+ const notifyMainSession = async (mainSessionID: string, res: { runId: string; status: string; name: string; error?: string; result?: string }): Promise<void> => {
118
+ const lines: string[] = []
119
+ lines.push(`Workflow run "${res.name}" finished with status: ${res.status}.`)
120
+ if (res.error) lines.push(`Error: ${res.error}`)
121
+ if (res.result) lines.push("", "Final result:", res.result)
122
+ lines.push("", "Summarize the outcome for the user concisely (a few sentences). Full run details are available in /workflows.")
123
+ try {
124
+ await input.client.session.prompt({
125
+ path: { id: mainSessionID },
126
+ body: { parts: [{ type: "text", text: lines.join("\n") }] },
127
+ })
128
+ } catch (e) {
129
+ log("warn", `could not deliver workflow result to session: ${errMsg(e)}`)
130
+ }
131
+ }
132
+
133
+ const runsRootHere = () => runsRoot(projectRootOf(input.worktree, input.directory))
134
+
135
+ /**
136
+ * Restart a run in place: same runId, same script, completed agents replayed
137
+ * from journal.jsonl. Used by the TUI (control.json {action:"resume"} on a run
138
+ * with no live engine) and by the tool's `resumeRunId`.
139
+ */
140
+ const resumeRun = async (
141
+ runId: string,
142
+ opts: { runsRoot: string; notifySessionID?: string },
143
+ ): Promise<{ ok: true; name: string; replayable: number } | { ok: false; reason: string }> => {
144
+ if (active.has(runId)) return { ok: false, reason: "run is already live in this opencode" }
145
+ const prior = loadPriorRun(opts.runsRoot, runId)
146
+ if (!prior) return { ok: false, reason: "no state.json/script.js for that run" }
147
+ if (prior.state.status === "completed") return { ok: false, reason: "run already completed" }
148
+ const mainSessionID = prior.state.mainSessionID ?? opts.notifySessionID ?? ""
149
+ const availableModels = await fetchModels()
150
+ const engine = new RunEngine(
151
+ {
152
+ client: input.client as any,
153
+ opencodeDir: join(prior.state.directory || projectRootOf(input.worktree, input.directory), ".opencode"),
154
+ runsRoot: opts.runsRoot,
155
+ mainSessionID,
156
+ defaultModel: prior.state.defaultModel ?? (mainSessionID ? sessionModel.get(mainSessionID) : undefined),
157
+ availableModels,
158
+ runArgs: prior.state.args,
159
+ onChildSession: (_agentId, sessionId) => {
160
+ childSessions.add(sessionId)
161
+ childSessionRun.set(sessionId, runId)
162
+ },
163
+ log,
164
+ },
165
+ runId,
166
+ )
167
+ active.set(runId, engine)
168
+ log("info", `resuming workflow run ${runId} (${prior.replayable} agents replay)`)
169
+ engine
170
+ .run({ resume: prior })
171
+ .then((res) => {
172
+ active.delete(runId)
173
+ for (const [sid, rid] of childSessionRun) if (rid === runId) childSessionRun.delete(sid)
174
+ const target = opts.notifySessionID ?? prior.state.mainSessionID
175
+ if (target) notifyMainSession(target, res)
176
+ })
177
+ .catch((e) => {
178
+ active.delete(runId)
179
+ log("error", `resumed workflow run ${runId} crashed: ${errMsg(e)}`)
180
+ })
181
+ return { ok: true, name: prior.state.name, replayable: prior.replayable }
182
+ }
183
+
184
+ // The TUI can only write files. A live engine consumes its own control.json;
185
+ // a control file next to a run with NO engine in this process is a request
186
+ // aimed at us: "resume" restarts the run, anything else is stale and dropped.
187
+ const pollOrphanControls = () => {
188
+ let names: string[]
189
+ const root = runsRootHere()
190
+ try {
191
+ names = readdirSync(root).filter((n) => n.startsWith("run_"))
192
+ } catch {
193
+ return
194
+ }
195
+ for (const runId of names) {
196
+ if (active.has(runId)) continue
197
+ const cp = controlPath(root, runId)
198
+ let ctl: any
199
+ try {
200
+ if (!existsSync(cp)) continue
201
+ ctl = JSON.parse(readFileSync(cp, "utf8"))
202
+ } catch {
203
+ continue
204
+ }
205
+ try {
206
+ rmSync(cp, { force: true })
207
+ } catch {}
208
+ const wantResume = ctl?.action === "resume" || ctl?.resume === true
209
+ if (!wantResume) continue
210
+ resumeRun(runId, { runsRoot: root })
211
+ .then((r) => {
212
+ if (!r.ok) log("warn", `cannot resume ${runId}: ${r.reason}`)
213
+ })
214
+ .catch((e) => log("error", `resume ${runId} failed: ${errMsg(e)}`))
215
+ }
216
+ }
217
+ const orphanTimer = setInterval(pollOrphanControls, 1000)
218
+
219
+ const workflowTool = tool({
220
+ description: TOOL_DESCRIPTION,
221
+ args: {
222
+ script: tool.schema.string().optional().describe("Inline workflow script (must start with `export const meta = { name, description, phases? }`)"),
223
+ scriptPath: tool.schema.string().optional().describe("Path to a workflow script file"),
224
+ name: tool.schema.string().optional().describe("Saved workflow name (.opencode/workflows/<name>.js)"),
225
+ args: tool.schema.any().optional().describe("Value passed to the script as global `args` (real JSON, not a stringified list)"),
226
+ resumeRunId: tool.schema.string().optional().describe("Resume a stopped run (its engine died) by runId: completed agents replay, the rest run again"),
227
+ },
228
+ execute: async (args, ctx) => {
229
+ if (args.resumeRunId) {
230
+ const r = await resumeRun(args.resumeRunId, {
231
+ runsRoot: runsRoot(projectRootOf(input.worktree, ctx.directory)),
232
+ notifySessionID: ctx.sessionID,
233
+ })
234
+ if (!r.ok) return { title: "workflow: cannot resume", output: `Run ${args.resumeRunId} cannot be resumed: ${r.reason}` }
235
+ return {
236
+ title: `workflow: ${r.name} resumed`,
237
+ output: `Workflow "${r.name}" resumed (runId ${args.resumeRunId}); ${r.replayable} completed agent(s) replay from the journal, the rest run again. A result turn will arrive automatically when it finishes — do not poll.`,
238
+ metadata: { runId: args.resumeRunId },
239
+ }
240
+ }
241
+ if (!args.script && !args.scriptPath && !args.name)
242
+ return { title: "workflow: missing input", output: "Provide one of: script (inline), scriptPath, name (saved workflow), or resumeRunId." }
243
+
244
+ let plan: { name: string; description: string; phases: string[] }
245
+ try {
246
+ plan = preview(args)
247
+ } catch (e: any) {
248
+ return { title: "workflow: invalid script", output: `Could not parse workflow: ${e?.message ?? e}` }
249
+ }
250
+
251
+ const runId = generateRunId()
252
+ const availableModels = await fetchModels()
253
+ const engine = new RunEngine(
254
+ {
255
+ client: input.client as any,
256
+ opencodeDir: opencodeDirOf(input.worktree, ctx.directory),
257
+ runsRoot: runsRoot(projectRootOf(input.worktree, ctx.directory)),
258
+ mainSessionID: ctx.sessionID,
259
+ defaultModel: sessionModel.get(ctx.sessionID),
260
+ availableModels,
261
+ runArgs: args.args,
262
+ onChildSession: (agentId, sessionId) => {
263
+ childSessions.add(sessionId)
264
+ childSessionRun.set(sessionId, runId)
265
+ },
266
+ log,
267
+ },
268
+ runId,
269
+ )
270
+ active.set(runId, engine)
271
+
272
+ // Plan approval through the standard permission flow.
273
+ await ctx.ask({
274
+ permission: "workflow",
275
+ patterns: [plan.name],
276
+ always: [plan.name],
277
+ metadata: {
278
+ description: plan.description,
279
+ phases: plan.phases,
280
+ runId,
281
+ hint: "workflow plan approval — phases shown; use /workflows to watch progress",
282
+ },
283
+ })
284
+
285
+ engine
286
+ .run({ script: args.script, scriptPath: args.scriptPath, name: args.name, args: args.args })
287
+ .then((res) => {
288
+ active.delete(runId)
289
+ for (const [sid, rid] of childSessionRun) if (rid === runId) childSessionRun.delete(sid)
290
+ notifyMainSession(ctx.sessionID, res)
291
+ })
292
+ .catch((e) => {
293
+ active.delete(runId)
294
+ log("error", `workflow run ${runId} crashed: ${errMsg(e)}`)
295
+ })
296
+
297
+ return {
298
+ title: `workflow: ${plan.name} started`,
299
+ output: `Workflow "${plan.name}" is now running (runId ${runId}). Phases: ${plan.phases.join(" → ") || "auto"}. The user can watch progress, pause, or stop it in /workflows. A result turn will arrive automatically when the run finishes — do not poll.`,
300
+ metadata: { runId },
301
+ }
302
+ },
303
+ })
304
+
305
+ return {
306
+ tool: { workflow: workflowTool },
307
+
308
+ // Runs once at startup with the merged config object that opencode later
309
+ // hands to skill discovery, so pushing here is enough to make the bundled
310
+ // skills visible in this and every other project the plugin is loaded in.
311
+ config: async (cfg) => {
312
+ let isDir = false
313
+ try {
314
+ isDir = statSync(BUNDLED_SKILLS_DIR).isDirectory()
315
+ } catch {}
316
+ if (!isDir) {
317
+ log("warn", "bundled skills directory missing; workflow-authoring skill unavailable", { dir: BUNDLED_SKILLS_DIR })
318
+ return
319
+ }
320
+ const c = cfg as { skills?: { paths?: string[]; urls?: string[] } }
321
+ c.skills ??= {}
322
+ c.skills.paths ??= []
323
+ if (!c.skills.paths.includes(BUNDLED_SKILLS_DIR)) c.skills.paths.push(BUNDLED_SKILLS_DIR)
324
+ log("debug", "registered bundled skills", { dir: BUNDLED_SKILLS_DIR })
325
+ },
326
+
327
+ "chat.params": async (i) => {
328
+ const label = modelLabel(i.model?.providerID, i.model?.id)
329
+ if (label && !childSessions.has(i.sessionID)) sessionModel.set(i.sessionID, label)
330
+ },
331
+
332
+ "chat.message": async (i, o) => {
333
+ if (childSessions.has(i.sessionID)) return
334
+ const text = (o.parts ?? []).map((p) => (p as any).type === "text" ? (p as any).text ?? "" : "").join("\n")
335
+ if (KEYWORD_RE.test(text)) {
336
+ o.parts.push({
337
+ type: "text",
338
+ text: KEYWORD_DIRECTIVE,
339
+ id: `prt_workflow_request_${Math.random().toString(36).slice(2, 8)}`,
340
+ } as any)
341
+ }
342
+ },
343
+
344
+ "experimental.chat.system.transform": async (i, o) => {
345
+ if (i.sessionID && childSessions.has(i.sessionID)) return
346
+ o.system.push(SYSTEM_GUIDANCE)
347
+ },
348
+
349
+ event: async ({ event }) => {
350
+ const e = event as any
351
+ if (e?.type === "message.part.updated") {
352
+ const part = e.properties?.part
353
+ const runId = part?.sessionID ? childSessionRun.get(part.sessionID) : undefined
354
+ if (runId) active.get(runId)?.onPartUpdated(part)
355
+ }
356
+ if (e?.type === "message.updated") {
357
+ const m = e.properties?.info ?? e.properties?.message ?? (e.properties?.id ? e.properties : undefined)
358
+ const runId = m?.sessionID ? childSessionRun.get(m.sessionID) : undefined
359
+ if (runId) active.get(runId)?.onMessageUpdated(m)
360
+ }
361
+ },
362
+
363
+ dispose: async () => {
364
+ clearInterval(orphanTimer)
365
+ // opencode is going away: close out live runs so their state files do
366
+ // not claim "running" forever (the TUI would otherwise refuse to delete them).
367
+ // The run folder stays intact and can be resumed (p in /workflows).
368
+ for (const engine of active.values()) {
369
+ try {
370
+ engine.shutdown("opencode exited while the workflow was running — press p in /workflows to resume")
371
+ } catch (e) {
372
+ log("warn", `workflow shutdown failed: ${errMsg(e)}`)
373
+ }
374
+ }
375
+ active.clear()
376
+ },
377
+ }
378
+ }
379
+
380
+ // --- small helpers -----------------------------------------------------------
381
+
382
+ function safeName(n: string): string {
383
+ return n.replace(/[^a-zA-Z0-9_-]/g, "-")
384
+ }
385
+
386
+ function errMsg(e: unknown): string {
387
+ if (e instanceof Error) return e.message
388
+ if (typeof e === "string") return e
389
+ try {
390
+ return JSON.stringify(e)
391
+ } catch {
392
+ return String(e)
393
+ }
394
+ }
@@ -0,0 +1,123 @@
1
+ export function fmtTokens(n: number | undefined): string {
2
+ if (!n) return "0 tok"
3
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M tok`
4
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k tok`
5
+ return `${Math.round(n)} tok`
6
+ }
7
+
8
+ /** compact token count for table cells: "58.1k", "1.2M", "512" */
9
+ export function fmtTok(n: number | undefined): string {
10
+ if (!n) return "0"
11
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
12
+ if (n >= 10_000) return `${Math.round(n / 1000)}k`
13
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
14
+ return String(Math.round(n))
15
+ }
16
+
17
+ /** "$0.0421", "$1.23", "$0" */
18
+ export function fmtCost(n: number | undefined): string {
19
+ if (!n) return "$0"
20
+ if (n < 0.01) return `$${n.toFixed(4)}`
21
+ if (n < 1) return `$${n.toFixed(3)}`
22
+ return `$${n.toFixed(2)}`
23
+ }
24
+
25
+ export function fmtDuration(ms: number | undefined, maxMs?: number): string {
26
+ if (ms == null) return "—"
27
+ const t = maxMs != null ? Math.min(ms, maxMs) : ms
28
+ if (t < 1000) return "0s"
29
+ const s = Math.floor(t / 1000)
30
+ if (s < 60) return `${s}s`
31
+ const m = Math.floor(s / 60)
32
+ const rs = s % 60
33
+ if (m < 60) return rs ? `${m}m ${rs}s` : `${m}m`
34
+ const h = Math.floor(m / 60)
35
+ const rm = m % 60
36
+ return rm ? `${h}h ${rm}m` : `${h}h`
37
+ }
38
+
39
+ /** elapsed for a still-running agent */
40
+ export function fmtElapsed(startedAt: number | undefined, endedAt: number | undefined, now: number): string {
41
+ return fmtDuration(endedAt != null ? endedAt - (startedAt ?? endedAt) : startedAt ? now - startedAt : undefined)
42
+ }
43
+
44
+ /** "just now", "3m ago", "2h ago", "yesterday", "3d ago" */
45
+ export function fmtAgo(ts: number | undefined, now: number): string {
46
+ if (!ts) return "—"
47
+ const s = Math.max(0, Math.floor((now - ts) / 1000))
48
+ if (s < 45) return "just now"
49
+ const m = Math.floor(s / 60)
50
+ if (m < 60) return `${m}m ago`
51
+ const h = Math.floor(m / 60)
52
+ if (h < 24) return `${h}h ago`
53
+ const d = Math.floor(h / 24)
54
+ if (d === 1) return "yesterday"
55
+ return `${d}d ago`
56
+ }
57
+
58
+ export function fmtClock(ts: number | undefined): string {
59
+ if (!ts) return "—"
60
+ const d = new Date(ts)
61
+ const p = (n: number) => String(n).padStart(2, "0")
62
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
63
+ }
64
+
65
+ /** "anthropic/claude-sonnet-4-6" -> "claude-sonnet-4-6"; keeps provider for unknown tiers */
66
+ export function shortModel(model: string | undefined): string {
67
+ if (!model) return "default"
68
+ const i = model.indexOf("/")
69
+ return i >= 0 ? model.slice(i + 1) : model
70
+ }
71
+
72
+ /** clamp a string to n chars with an ellipsis */
73
+ export function clip(s: string, n: number): string {
74
+ if (n <= 0) return ""
75
+ if (s.length <= n) return s
76
+ if (n === 1) return "…"
77
+ return s.slice(0, n - 1) + "…"
78
+ }
79
+
80
+ /** clip then pad to exactly n chars (left-aligned) */
81
+ export function cell(s: string, n: number): string {
82
+ return clip(s, n).padEnd(n)
83
+ }
84
+
85
+ /** clip then pad to exactly n chars (right-aligned) */
86
+ export function cellR(s: string, n: number): string {
87
+ return clip(s, n).padStart(n)
88
+ }
89
+
90
+ /** collapse whitespace/newlines into one line */
91
+ export function oneLine(s: string | undefined): string {
92
+ return String(s ?? "").replace(/\s+/g, " ").trim()
93
+ }
94
+
95
+ /** word-wrap text to a column width, preserving explicit newlines */
96
+ export function wrapWords(text: string | undefined, width: number): string[] {
97
+ const w = Math.max(8, width | 0)
98
+ const out: string[] = []
99
+ for (const raw of String(text ?? "").replace(/\r/g, "").split("\n")) {
100
+ const line = raw.replace(/\t/g, " ")
101
+ if (line.length <= w) {
102
+ out.push(line)
103
+ continue
104
+ }
105
+ let cur = ""
106
+ for (const word of line.split(" ")) {
107
+ if (word.length > w) {
108
+ if (cur) out.push(cur)
109
+ cur = ""
110
+ for (let i = 0; i < word.length; i += w) out.push(word.slice(i, i + w))
111
+ continue
112
+ }
113
+ if (!cur) cur = word
114
+ else if (cur.length + 1 + word.length <= w) cur += " " + word
115
+ else {
116
+ out.push(cur)
117
+ cur = word
118
+ }
119
+ }
120
+ if (cur) out.push(cur)
121
+ }
122
+ return out
123
+ }