opencode-planpilot 0.2.3 → 0.2.4

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,746 @@
1
+ import { PlanpilotApp } from "../lib/app"
2
+ import { AppError, invalidInput } from "../lib/errors"
3
+ import {
4
+ loadPlanpilotConfig,
5
+ normalizePlanpilotConfig,
6
+ savePlanpilotConfig,
7
+ type PlanpilotConfig,
8
+ } from "../lib/config"
9
+ import { openDatabase } from "../lib/db"
10
+ import { parseWaitFromComment } from "../lib/util"
11
+ import type {
12
+ GoalQuery,
13
+ GoalStatus,
14
+ PlanDetail,
15
+ PlanOrder,
16
+ PlanStatus,
17
+ StepDetail,
18
+ StepExecutor,
19
+ StepOrder,
20
+ StepQuery,
21
+ StepStatus,
22
+ } from "../lib/models"
23
+
24
+ type JsonPrimitive = string | number | boolean | null
25
+ type JsonObject = object
26
+ type JsonValue = JsonPrimitive | JsonObject | JsonValue[]
27
+
28
+ type BridgeRequest = {
29
+ action: string
30
+ payload?: unknown
31
+ context?: unknown
32
+ plugin?: unknown
33
+ }
34
+
35
+ type BridgeSuccess = {
36
+ ok: true
37
+ data: JsonValue
38
+ }
39
+
40
+ type BridgeFailure = {
41
+ ok: false
42
+ error: {
43
+ code: string
44
+ message: string
45
+ details: JsonValue | null
46
+ }
47
+ }
48
+
49
+ type BridgeResponse = BridgeSuccess | BridgeFailure
50
+
51
+ type BridgeRequestContext = {
52
+ sessionId: string
53
+ cwd?: string
54
+ }
55
+
56
+ type ActionHandler = (payload: unknown, context: BridgeRequestContext) => JsonValue
57
+
58
+ const DEFAULT_SESSION_ID = "studio"
59
+
60
+ const PLAN_UPDATE_ALLOWED = new Set(["title", "content", "status", "comment"])
61
+ const STEP_UPDATE_ALLOWED = new Set(["content", "status", "executor", "comment"])
62
+ const GOAL_UPDATE_ALLOWED = new Set(["content", "status", "comment"])
63
+
64
+ async function main() {
65
+ const response = await runBridge()
66
+ process.stdout.write(JSON.stringify(response))
67
+ }
68
+
69
+ async function runBridge(): Promise<BridgeResponse> {
70
+ try {
71
+ const raw = await readStdinOnce()
72
+ const request = parseRequest(raw)
73
+ const context = resolveRequestContext(request)
74
+ const data = dispatch(request.action, request.payload, context)
75
+ return ok(data)
76
+ } catch (error) {
77
+ return fail(error)
78
+ }
79
+ }
80
+
81
+ function parseRequest(raw: string): BridgeRequest {
82
+ if (!raw.trim()) {
83
+ throw invalidInput("bridge request is empty")
84
+ }
85
+
86
+ let parsed: unknown
87
+ try {
88
+ parsed = JSON.parse(raw)
89
+ } catch (error) {
90
+ const message = error instanceof Error ? error.message : String(error)
91
+ throw invalidInput(`bridge request is not valid JSON: ${message}`)
92
+ }
93
+
94
+ const root = asObject(parsed, "bridge request")
95
+ const action = expectString(root.action, "action")
96
+ return {
97
+ action,
98
+ payload: root.payload,
99
+ context: root.context,
100
+ plugin: root.plugin,
101
+ }
102
+ }
103
+
104
+ function resolveRequestContext(request: BridgeRequest): BridgeRequestContext {
105
+ const context = asObjectOptional(request.context)
106
+ const plugin = asObjectOptional(request.plugin)
107
+ const sessionId =
108
+ readNonEmptyString(context?.sessionId) ?? readNonEmptyString(context?.sessionID) ?? DEFAULT_SESSION_ID
109
+ const cwd =
110
+ readNonEmptyString(context?.cwd) ??
111
+ readNonEmptyString(context?.directory) ??
112
+ readNonEmptyString(plugin?.rootPath) ??
113
+ undefined
114
+ return { sessionId, cwd }
115
+ }
116
+
117
+ function createApp(context: BridgeRequestContext): PlanpilotApp {
118
+ return new PlanpilotApp(openDatabase(), context.sessionId, context.cwd)
119
+ }
120
+
121
+ function dispatch(action: string, payload: unknown, context: BridgeRequestContext): JsonValue {
122
+ const handler = ACTIONS[action]
123
+ if (!handler) {
124
+ throw invalidInput(`unknown action: ${action}`)
125
+ }
126
+ return handler(payload, context)
127
+ }
128
+
129
+ const ACTIONS: Record<string, ActionHandler> = {
130
+ "runtime.snapshot": actionRuntimeSnapshot,
131
+ "runtime.next": actionRuntimeNext,
132
+ "runtime.pause": actionRuntimePause,
133
+ "runtime.resume": actionRuntimeResume,
134
+ "events.poll": actionEventsPoll,
135
+ "config.get": actionConfigGet,
136
+ "config.set": actionConfigSet,
137
+ "plan.list": actionPlanList,
138
+ "plan.get": actionPlanGet,
139
+ "plan.createTree": actionPlanAddTree,
140
+ "plan.addTree": actionPlanAddTree,
141
+ "plan.update": actionPlanUpdate,
142
+ "plan.done": actionPlanDone,
143
+ "plan.remove": actionPlanRemove,
144
+ "plan.activate": actionPlanActivate,
145
+ "plan.deactivate": actionPlanDeactivate,
146
+ "plan.active": actionPlanActive,
147
+ "step.list": actionStepList,
148
+ "step.get": actionStepGet,
149
+ "step.add": actionStepAdd,
150
+ "step.addTree": actionStepAddTree,
151
+ "step.update": actionStepUpdate,
152
+ "step.done": actionStepDone,
153
+ "step.remove": actionStepRemove,
154
+ "step.move": actionStepMove,
155
+ "step.wait": actionStepWait,
156
+ "goal.list": actionGoalList,
157
+ "goal.get": actionGoalGet,
158
+ "goal.add": actionGoalAdd,
159
+ "goal.update": actionGoalUpdate,
160
+ "goal.done": actionGoalDone,
161
+ "goal.remove": actionGoalRemove,
162
+ }
163
+
164
+ function actionRuntimeSnapshot(_payload: unknown, context: BridgeRequestContext): JsonValue {
165
+ const app = createApp(context)
166
+ return buildRuntimeSnapshot(app, loadPlanpilotConfig().config)
167
+ }
168
+
169
+ function actionRuntimeNext(_payload: unknown, context: BridgeRequestContext): JsonValue {
170
+ const app = createApp(context)
171
+ const active = app.getActivePlan()
172
+ if (!active) {
173
+ return {
174
+ activePlan: null,
175
+ nextStep: null,
176
+ cursor: buildRuntimeCursor(app, loadPlanpilotConfig().config),
177
+ }
178
+ }
179
+
180
+ const nextStep = app.nextStep(active.plan_id)
181
+ return {
182
+ activePlan: active,
183
+ nextStep: nextStep ? serializeStepDetail(app.getStepDetail(nextStep.id)) : null,
184
+ cursor: buildRuntimeCursor(app, loadPlanpilotConfig().config),
185
+ }
186
+ }
187
+
188
+ function actionRuntimePause(_payload: unknown, context: BridgeRequestContext): JsonValue {
189
+ const loaded = loadPlanpilotConfig()
190
+ const config = normalizePlanpilotConfig(loaded.config)
191
+ config.runtime.paused = true
192
+ savePlanpilotConfig(config)
193
+ const app = createApp(context)
194
+ return buildRuntimeSnapshot(app, config)
195
+ }
196
+
197
+ function actionRuntimeResume(_payload: unknown, context: BridgeRequestContext): JsonValue {
198
+ const loaded = loadPlanpilotConfig()
199
+ const config = normalizePlanpilotConfig(loaded.config)
200
+ config.runtime.paused = false
201
+ savePlanpilotConfig(config)
202
+ const app = createApp(context)
203
+ return buildRuntimeSnapshot(app, config)
204
+ }
205
+
206
+ function actionEventsPoll(payload: unknown, context: BridgeRequestContext): JsonValue {
207
+ const app = createApp(context)
208
+ const config = loadPlanpilotConfig().config
209
+ const currentCursor = buildRuntimeCursor(app, config)
210
+
211
+ const input = asObjectOptional(payload)
212
+ const previousCursor = readNonEmptyString(input?.cursor) ?? ""
213
+ if (previousCursor === currentCursor) {
214
+ return {
215
+ cursor: currentCursor,
216
+ events: [],
217
+ }
218
+ }
219
+
220
+ return {
221
+ cursor: currentCursor,
222
+ events: [
223
+ {
224
+ event: "planpilot.runtime.changed",
225
+ id: currentCursor,
226
+ data: buildRuntimeSnapshot(app, config),
227
+ },
228
+ ],
229
+ }
230
+ }
231
+
232
+ function actionConfigGet(_payload: unknown, _context: BridgeRequestContext): JsonValue {
233
+ const loaded = loadPlanpilotConfig()
234
+ return loaded.config as unknown as JsonValue
235
+ }
236
+
237
+ function actionConfigSet(payload: unknown, _context: BridgeRequestContext): JsonValue {
238
+ const root = asObject(payload, "config.set payload")
239
+ const raw = "config" in root ? root.config : payload
240
+ const normalized = normalizePlanpilotConfig(raw)
241
+ const saved = savePlanpilotConfig(normalized)
242
+ return {
243
+ path: saved.path,
244
+ config: saved.config as unknown as JsonValue,
245
+ }
246
+ }
247
+
248
+ function actionPlanList(payload: unknown, context: BridgeRequestContext): JsonValue {
249
+ const app = createApp(context)
250
+ const input = asObjectOptional(payload)
251
+ const order = parsePlanOrderOptional(input?.order)
252
+ const desc = readBoolean(input?.desc) ?? true
253
+ const plans = app.listPlans(order, desc)
254
+ return plans as unknown as JsonValue
255
+ }
256
+
257
+ function actionPlanGet(payload: unknown, context: BridgeRequestContext): JsonValue {
258
+ const app = createApp(context)
259
+ const input = asObject(payload, "plan.get payload")
260
+ const id = expectInt(input.id, "id")
261
+ return serializePlanDetail(app.getPlanDetail(id))
262
+ }
263
+
264
+ function actionPlanAddTree(payload: unknown, context: BridgeRequestContext): JsonValue {
265
+ const app = createApp(context)
266
+ const input = asObject(payload, "plan.createTree payload")
267
+ const title = expectString(input.title, "title")
268
+ const content = expectString(input.content, "content")
269
+ const stepsInput = expectArray(input.steps, "steps")
270
+ const steps = stepsInput.map((item, index) => {
271
+ const step = asObject(item, `steps[${index}]`)
272
+ return {
273
+ content: expectString(step.content, `steps[${index}].content`),
274
+ executor: parseExecutorOptional(step.executor) ?? "ai",
275
+ goals: readStringArray(step.goals),
276
+ }
277
+ })
278
+
279
+ const result = app.addPlanTree({ title, content }, steps)
280
+ return {
281
+ plan: result.plan,
282
+ stepCount: result.stepCount,
283
+ goalCount: result.goalCount,
284
+ detail: serializePlanDetail(app.getPlanDetail(result.plan.id)),
285
+ }
286
+ }
287
+
288
+ function actionPlanUpdate(payload: unknown, context: BridgeRequestContext): JsonValue {
289
+ const app = createApp(context)
290
+ const input = asObject(payload, "plan.update payload")
291
+ const id = expectInt(input.id, "id")
292
+ assertAllowedKeys(input, PLAN_UPDATE_ALLOWED, "plan.update")
293
+ const result = app.updatePlanWithActiveClear(id, {
294
+ title: readString(input.title),
295
+ content: readString(input.content),
296
+ status: parsePlanStatusOptional(input.status),
297
+ comment: readNullableString(input.comment),
298
+ })
299
+ return {
300
+ plan: result.plan,
301
+ cleared: result.cleared,
302
+ }
303
+ }
304
+
305
+ function actionPlanDone(payload: unknown, context: BridgeRequestContext): JsonValue {
306
+ const app = createApp(context)
307
+ const input = asObject(payload, "plan.done payload")
308
+ const id = expectInt(input.id, "id")
309
+ return app.updatePlanWithActiveClear(id, { status: "done" }) as unknown as JsonValue
310
+ }
311
+
312
+ function actionPlanRemove(payload: unknown, context: BridgeRequestContext): JsonValue {
313
+ const app = createApp(context)
314
+ const input = asObject(payload, "plan.remove payload")
315
+ const id = expectInt(input.id, "id")
316
+ app.deletePlan(id)
317
+ return { removed: id }
318
+ }
319
+
320
+ function actionPlanActivate(payload: unknown, context: BridgeRequestContext): JsonValue {
321
+ const app = createApp(context)
322
+ const input = asObject(payload, "plan.activate payload")
323
+ const id = expectInt(input.id, "id")
324
+ const force = readBoolean(input.force) ?? false
325
+ const plan = app.getPlan(id)
326
+ if (plan.status === "done") {
327
+ throw invalidInput("cannot activate plan; plan is done")
328
+ }
329
+ return app.setActivePlan(id, force) as unknown as JsonValue
330
+ }
331
+
332
+ function actionPlanDeactivate(_payload: unknown, context: BridgeRequestContext): JsonValue {
333
+ const app = createApp(context)
334
+ const active = app.getActivePlan()
335
+ app.clearActivePlan()
336
+ return {
337
+ activePlan: active,
338
+ deactivated: true,
339
+ }
340
+ }
341
+
342
+ function actionPlanActive(_payload: unknown, context: BridgeRequestContext): JsonValue {
343
+ const app = createApp(context)
344
+ const active = app.getActivePlan()
345
+ if (!active) {
346
+ return { activePlan: null, detail: null }
347
+ }
348
+ return {
349
+ activePlan: active,
350
+ detail: serializePlanDetail(app.getPlanDetail(active.plan_id)),
351
+ }
352
+ }
353
+
354
+ function actionStepList(payload: unknown, context: BridgeRequestContext): JsonValue {
355
+ const app = createApp(context)
356
+ const input = asObject(payload, "step.list payload")
357
+ const planId = expectInt(input.planId, "planId")
358
+ const query: StepQuery = {
359
+ status: parseStepStatusOptional(input.status),
360
+ executor: parseExecutorOptional(input.executor),
361
+ limit: parseIntOptional(input.limit),
362
+ offset: parseIntOptional(input.offset),
363
+ order: parseStepOrderOptional(input.order),
364
+ desc: readBoolean(input.desc),
365
+ }
366
+ const steps = app.listStepsFiltered(planId, query)
367
+ return steps as unknown as JsonValue
368
+ }
369
+
370
+ function actionStepGet(payload: unknown, context: BridgeRequestContext): JsonValue {
371
+ const app = createApp(context)
372
+ const input = asObject(payload, "step.get payload")
373
+ const id = expectInt(input.id, "id")
374
+ return serializeStepDetail(app.getStepDetail(id))
375
+ }
376
+
377
+ function actionStepAdd(payload: unknown, context: BridgeRequestContext): JsonValue {
378
+ const app = createApp(context)
379
+ const input = asObject(payload, "step.add payload")
380
+ const planId = expectInt(input.planId, "planId")
381
+ const contents = resolveContents(input, "content", "contents")
382
+ const executor = parseExecutorOptional(input.executor) ?? "ai"
383
+ const at = parseIntOptional(input.at)
384
+ const result = app.addStepsBatch(planId, contents, "todo", executor, at)
385
+ return {
386
+ steps: result.steps,
387
+ changes: result.changes,
388
+ }
389
+ }
390
+
391
+ function actionStepAddTree(payload: unknown, context: BridgeRequestContext): JsonValue {
392
+ const app = createApp(context)
393
+ const input = asObject(payload, "step.addTree payload")
394
+ const planId = expectInt(input.planId, "planId")
395
+ const content = expectString(input.content, "content")
396
+ const executor = parseExecutorOptional(input.executor) ?? "ai"
397
+ const goals = readStringArray(input.goals)
398
+ return app.addStepTree(planId, content, executor, goals) as unknown as JsonValue
399
+ }
400
+
401
+ function actionStepUpdate(payload: unknown, context: BridgeRequestContext): JsonValue {
402
+ const app = createApp(context)
403
+ const input = asObject(payload, "step.update payload")
404
+ const id = expectInt(input.id, "id")
405
+ assertAllowedKeys(input, STEP_UPDATE_ALLOWED, "step.update")
406
+ return app.updateStep(id, {
407
+ content: readString(input.content),
408
+ status: parseStepStatusOptional(input.status),
409
+ executor: parseExecutorOptional(input.executor),
410
+ comment: readNullableString(input.comment),
411
+ }) as unknown as JsonValue
412
+ }
413
+
414
+ function actionStepDone(payload: unknown, context: BridgeRequestContext): JsonValue {
415
+ const app = createApp(context)
416
+ const input = asObject(payload, "step.done payload")
417
+ const id = expectInt(input.id, "id")
418
+ const allGoals = readBoolean(input.allGoals) ?? false
419
+ return app.setStepDoneWithGoals(id, allGoals) as unknown as JsonValue
420
+ }
421
+
422
+ function actionStepRemove(payload: unknown, context: BridgeRequestContext): JsonValue {
423
+ const app = createApp(context)
424
+ const input = asObject(payload, "step.remove payload")
425
+ const ids = resolveIds(input)
426
+ return app.deleteSteps(ids) as unknown as JsonValue
427
+ }
428
+
429
+ function actionStepMove(payload: unknown, context: BridgeRequestContext): JsonValue {
430
+ const app = createApp(context)
431
+ const input = asObject(payload, "step.move payload")
432
+ const id = expectInt(input.id, "id")
433
+ const to = expectInt(input.to, "to")
434
+ return app.moveStep(id, to) as unknown as JsonValue
435
+ }
436
+
437
+ function actionStepWait(payload: unknown, context: BridgeRequestContext): JsonValue {
438
+ const app = createApp(context)
439
+ const input = asObject(payload, "step.wait payload")
440
+ const id = expectInt(input.id, "id")
441
+ const clear = readBoolean(input.clear) ?? false
442
+ if (clear) {
443
+ return app.clearStepWait(id) as unknown as JsonValue
444
+ }
445
+
446
+ const delayMs = expectInt(input.delayMs, "delayMs")
447
+ const reason = readString(input.reason)
448
+ return app.setStepWait(id, delayMs, reason) as unknown as JsonValue
449
+ }
450
+
451
+ function actionGoalList(payload: unknown, context: BridgeRequestContext): JsonValue {
452
+ const app = createApp(context)
453
+ const input = asObject(payload, "goal.list payload")
454
+ const stepId = expectInt(input.stepId, "stepId")
455
+ const query: GoalQuery = {
456
+ status: parseGoalStatusOptional(input.status),
457
+ limit: parseIntOptional(input.limit),
458
+ offset: parseIntOptional(input.offset),
459
+ }
460
+ return app.listGoalsFiltered(stepId, query) as unknown as JsonValue
461
+ }
462
+
463
+ function actionGoalGet(payload: unknown, context: BridgeRequestContext): JsonValue {
464
+ const app = createApp(context)
465
+ const input = asObject(payload, "goal.get payload")
466
+ const id = expectInt(input.id, "id")
467
+ return app.getGoalDetail(id) as unknown as JsonValue
468
+ }
469
+
470
+ function actionGoalAdd(payload: unknown, context: BridgeRequestContext): JsonValue {
471
+ const app = createApp(context)
472
+ const input = asObject(payload, "goal.add payload")
473
+ const stepId = expectInt(input.stepId, "stepId")
474
+ const contents = resolveContents(input, "content", "contents")
475
+ return app.addGoalsBatch(stepId, contents, "todo") as unknown as JsonValue
476
+ }
477
+
478
+ function actionGoalUpdate(payload: unknown, context: BridgeRequestContext): JsonValue {
479
+ const app = createApp(context)
480
+ const input = asObject(payload, "goal.update payload")
481
+ const id = expectInt(input.id, "id")
482
+ assertAllowedKeys(input, GOAL_UPDATE_ALLOWED, "goal.update")
483
+ return app.updateGoal(id, {
484
+ content: readString(input.content),
485
+ status: parseGoalStatusOptional(input.status),
486
+ comment: readNullableString(input.comment),
487
+ }) as unknown as JsonValue
488
+ }
489
+
490
+ function actionGoalDone(payload: unknown, context: BridgeRequestContext): JsonValue {
491
+ const app = createApp(context)
492
+ const input = asObject(payload, "goal.done payload")
493
+ const ids = resolveIds(input)
494
+ if (ids.length === 1) {
495
+ return app.setGoalStatus(ids[0], "done") as unknown as JsonValue
496
+ }
497
+ return app.setGoalsStatus(ids, "done") as unknown as JsonValue
498
+ }
499
+
500
+ function actionGoalRemove(payload: unknown, context: BridgeRequestContext): JsonValue {
501
+ const app = createApp(context)
502
+ const input = asObject(payload, "goal.remove payload")
503
+ const ids = resolveIds(input)
504
+ return app.deleteGoals(ids) as unknown as JsonValue
505
+ }
506
+
507
+ function resolveContents(input: Record<string, unknown>, contentKey: string, contentsKey: string): string[] {
508
+ const content = readString(input[contentKey])
509
+ const contents = readStringArray(input[contentsKey])
510
+ const out = content ? [content, ...contents] : contents
511
+ if (!out.length) {
512
+ throw invalidInput(`expected ${contentKey} or ${contentsKey}`)
513
+ }
514
+ return out
515
+ }
516
+
517
+ function resolveIds(input: Record<string, unknown>): number[] {
518
+ const fromSingle = parseIntOptional(input.id)
519
+ const fromMany = readIntArray(input.ids)
520
+ const ids = fromSingle !== undefined ? [fromSingle, ...fromMany] : fromMany
521
+ if (!ids.length) {
522
+ throw invalidInput("expected id or ids")
523
+ }
524
+ return Array.from(new Set(ids))
525
+ }
526
+
527
+ function parsePlanOrderOptional(value: unknown): PlanOrder | undefined {
528
+ const raw = readString(value)
529
+ if (!raw) return undefined
530
+ if (raw === "id" || raw === "title" || raw === "created" || raw === "updated") return raw
531
+ throw invalidInput(`invalid plan order '${raw}', expected id|title|created|updated`)
532
+ }
533
+
534
+ function parseStepOrderOptional(value: unknown): StepOrder | undefined {
535
+ const raw = readString(value)
536
+ if (!raw) return undefined
537
+ if (raw === "order" || raw === "id" || raw === "created" || raw === "updated") return raw
538
+ throw invalidInput(`invalid step order '${raw}', expected order|id|created|updated`)
539
+ }
540
+
541
+ function parsePlanStatusOptional(value: unknown): PlanStatus | undefined {
542
+ const raw = readString(value)
543
+ if (!raw) return undefined
544
+ if (raw === "todo" || raw === "done") return raw
545
+ throw invalidInput(`invalid plan status '${raw}', expected todo|done`)
546
+ }
547
+
548
+ function parseStepStatusOptional(value: unknown): StepStatus | undefined {
549
+ const raw = parsePlanStatusOptional(value)
550
+ return raw as StepStatus | undefined
551
+ }
552
+
553
+ function parseGoalStatusOptional(value: unknown): GoalStatus | undefined {
554
+ const raw = parsePlanStatusOptional(value)
555
+ return raw as GoalStatus | undefined
556
+ }
557
+
558
+ function parseExecutorOptional(value: unknown): StepExecutor | undefined {
559
+ const raw = readString(value)
560
+ if (!raw) return undefined
561
+ if (raw === "ai" || raw === "human") return raw
562
+ throw invalidInput(`invalid executor '${raw}', expected ai|human`)
563
+ }
564
+
565
+ function assertAllowedKeys(input: Record<string, unknown>, allowed: Set<string>, action: string) {
566
+ for (const key of Object.keys(input)) {
567
+ if (key === "id") continue
568
+ if (!allowed.has(key)) {
569
+ throw invalidInput(`${action} does not support '${key}'`)
570
+ }
571
+ }
572
+ }
573
+
574
+ function buildRuntimeSnapshot(app: PlanpilotApp, config: PlanpilotConfig): JsonValue {
575
+ const active = app.getActivePlan()
576
+ const next = active ? app.nextStep(active.plan_id) : null
577
+ return {
578
+ paused: config.runtime.paused,
579
+ activePlan: active,
580
+ nextStep: next ? serializeStepDetail(app.getStepDetail(next.id)) : null,
581
+ cursor: buildRuntimeCursor(app, config),
582
+ }
583
+ }
584
+
585
+ function buildRuntimeCursor(app: PlanpilotApp, config: PlanpilotConfig): string {
586
+ const plans = app.listPlans("updated", true)
587
+ const latestPlanUpdated = plans.length ? plans[0].updated_at : 0
588
+ const active = app.getActivePlan()
589
+ const activeUpdated = active?.updated_at ?? 0
590
+ const activePlanId = active?.plan_id ?? 0
591
+ const next = active ? app.nextStep(active.plan_id) : null
592
+ const nextStepId = next?.id ?? 0
593
+ const paused = config.runtime.paused ? 1 : 0
594
+ return [paused, latestPlanUpdated, activeUpdated, activePlanId, nextStepId].join(":")
595
+ }
596
+
597
+ function serializePlanDetail(detail: PlanDetail): JsonValue {
598
+ const goals = detail.steps.map((step) => ({
599
+ stepId: step.id,
600
+ goals: detail.goals.get(step.id) ?? [],
601
+ }))
602
+ return {
603
+ plan: detail.plan,
604
+ steps: detail.steps,
605
+ goals,
606
+ }
607
+ }
608
+
609
+ function serializeStepDetail(detail: StepDetail): JsonValue {
610
+ const wait = parseWaitFromComment(detail.step.comment)
611
+ return {
612
+ step: detail.step,
613
+ goals: detail.goals,
614
+ wait,
615
+ }
616
+ }
617
+
618
+ function ok(data: JsonValue): BridgeSuccess {
619
+ return { ok: true, data }
620
+ }
621
+
622
+ function fail(error: unknown): BridgeFailure {
623
+ if (error instanceof AppError) {
624
+ const code =
625
+ error.kind === "InvalidInput"
626
+ ? "invalid_input"
627
+ : error.kind === "NotFound"
628
+ ? "not_found"
629
+ : error.kind === "Db"
630
+ ? "db_error"
631
+ : error.kind === "Io"
632
+ ? "io_error"
633
+ : "json_error"
634
+ return {
635
+ ok: false,
636
+ error: {
637
+ code,
638
+ message: error.toDisplayString(),
639
+ details: null,
640
+ },
641
+ }
642
+ }
643
+
644
+ const message = error instanceof Error ? error.message : String(error)
645
+ return {
646
+ ok: false,
647
+ error: {
648
+ code: "internal_error",
649
+ message,
650
+ details: null,
651
+ },
652
+ }
653
+ }
654
+
655
+ function asObject(value: unknown, label: string): Record<string, unknown> {
656
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
657
+ throw invalidInput(`${label} must be an object`)
658
+ }
659
+ return value as Record<string, unknown>
660
+ }
661
+
662
+ function asObjectOptional(value: unknown): Record<string, unknown> | undefined {
663
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
664
+ return undefined
665
+ }
666
+ return value as Record<string, unknown>
667
+ }
668
+
669
+ function expectString(value: unknown, label: string): string {
670
+ const parsed = readString(value)
671
+ if (!parsed) {
672
+ throw invalidInput(`${label} is required`)
673
+ }
674
+ return parsed
675
+ }
676
+
677
+ function readString(value: unknown): string | undefined {
678
+ if (typeof value !== "string") return undefined
679
+ const trimmed = value.trim()
680
+ return trimmed.length ? trimmed : undefined
681
+ }
682
+
683
+ function readNullableString(value: unknown): string | undefined {
684
+ if (value === null || value === undefined) return undefined
685
+ return readString(value)
686
+ }
687
+
688
+ function readNonEmptyString(value: unknown): string | undefined {
689
+ return readString(value)
690
+ }
691
+
692
+ function readBoolean(value: unknown): boolean | undefined {
693
+ return typeof value === "boolean" ? value : undefined
694
+ }
695
+
696
+ function expectInt(value: unknown, label: string): number {
697
+ const parsed = parseIntOptional(value)
698
+ if (parsed === undefined) {
699
+ throw invalidInput(`${label} must be an integer`)
700
+ }
701
+ return parsed
702
+ }
703
+
704
+ function parseIntOptional(value: unknown): number | undefined {
705
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) {
706
+ return undefined
707
+ }
708
+ return value
709
+ }
710
+
711
+ function readStringArray(value: unknown): string[] {
712
+ if (!Array.isArray(value)) return []
713
+ const out: string[] = []
714
+ for (const item of value) {
715
+ const text = readString(item)
716
+ if (text) out.push(text)
717
+ }
718
+ return out
719
+ }
720
+
721
+ function readIntArray(value: unknown): number[] {
722
+ if (!Array.isArray(value)) return []
723
+ const out: number[] = []
724
+ for (const item of value) {
725
+ const id = parseIntOptional(item)
726
+ if (id !== undefined) out.push(id)
727
+ }
728
+ return out
729
+ }
730
+
731
+ function expectArray(value: unknown, label: string): unknown[] {
732
+ if (!Array.isArray(value)) {
733
+ throw invalidInput(`${label} must be an array`)
734
+ }
735
+ return value
736
+ }
737
+
738
+ async function readStdinOnce(): Promise<string> {
739
+ const chunks: Buffer[] = []
740
+ for await (const chunk of process.stdin) {
741
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
742
+ }
743
+ return Buffer.concat(chunks).toString("utf8")
744
+ }
745
+
746
+ void main()