opencode-extended-sidebar 0.2.90

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 (105) hide show
  1. package/.oesignore +17 -0
  2. package/CHANGELOG.md +95 -0
  3. package/LICENSE +21 -0
  4. package/README.md +235 -0
  5. package/oes.json +11 -0
  6. package/package.json +100 -0
  7. package/scripts/install-git-hooks.mjs +29 -0
  8. package/src/pware.oc.core/constants/index.ts +18 -0
  9. package/src/pware.oc.core/constants/pware.oc.core.constants.eventKind.ts +29 -0
  10. package/src/pware.oc.core/constants/pware.oc.core.constants.eventName.ts +13 -0
  11. package/src/pware.oc.core/constants/pware.oc.core.constants.eventType.ts +241 -0
  12. package/src/pware.oc.core/constants/pware.oc.core.constants.myWork.ts +55 -0
  13. package/src/pware.oc.core/constants/pware.oc.core.constants.partType.ts +65 -0
  14. package/src/pware.oc.core/constants/pware.oc.core.constants.phase.ts +58 -0
  15. package/src/pware.oc.core/constants/pware.oc.core.constants.pulse.ts +63 -0
  16. package/src/pware.oc.core/constants/pware.oc.core.constants.rowKind.ts +34 -0
  17. package/src/pware.oc.core/constants/pware.oc.core.constants.status.ts +74 -0
  18. package/src/pware.oc.core/constants/pware.oc.core.constants.toolName.ts +69 -0
  19. package/src/pware.oc.core/git/index.ts +7 -0
  20. package/src/pware.oc.core/git/pware.oc.core.git.ts +237 -0
  21. package/src/pware.oc.core/git/pware.oc.core.gitignore.ts +87 -0
  22. package/src/pware.oc.core/index.ts +18 -0
  23. package/src/pware.oc.core/pware.oc.core.bus.ts +42 -0
  24. package/src/pware.oc.core/pware.oc.core.cache.ts +48 -0
  25. package/src/pware.oc.core/pware.oc.core.clipboard.ts +97 -0
  26. package/src/pware.oc.core/pware.oc.core.debug.ts +280 -0
  27. package/src/pware.oc.core/pware.oc.core.events.ts +79 -0
  28. package/src/pware.oc.core/pware.oc.core.glyph.ts +135 -0
  29. package/src/pware.oc.core/pware.oc.core.layout.ts +234 -0
  30. package/src/pware.oc.core/pware.oc.core.oes.ts +89 -0
  31. package/src/pware.oc.core/pware.oc.core.paths.ts +240 -0
  32. package/src/pware.oc.core/pware.oc.core.preview.ts +74 -0
  33. package/src/pware.oc.core/pware.oc.core.pulse.ts +726 -0
  34. package/src/pware.oc.core/pware.oc.core.sqlite.ts +175 -0
  35. package/src/pware.oc.core/pware.oc.core.status.ts +214 -0
  36. package/src/pware.oc.core/pware.oc.core.timing.ts +63 -0
  37. package/src/pware.oc.core/pware.oc.core.width.ts +215 -0
  38. package/src/pware.oc.omo/constants/index.ts +15 -0
  39. package/src/pware.oc.omo/constants/pware.oc.omo.constants.backgroundTask.ts +18 -0
  40. package/src/pware.oc.omo/constants/pware.oc.omo.constants.boulderStatus.ts +49 -0
  41. package/src/pware.oc.omo/constants/pware.oc.omo.constants.docKind.ts +29 -0
  42. package/src/pware.oc.omo/constants/pware.oc.omo.constants.eventName.ts +11 -0
  43. package/src/pware.oc.omo/constants/pware.oc.omo.constants.planStatus.ts +60 -0
  44. package/src/pware.oc.omo/constants/pware.oc.omo.constants.reviewStatus.ts +49 -0
  45. package/src/pware.oc.omo/constants/pware.oc.omo.constants.startWork.ts +25 -0
  46. package/src/pware.oc.omo/constants/pware.oc.omo.constants.verdict.ts +38 -0
  47. package/src/pware.oc.omo/index.ts +7 -0
  48. package/src/pware.oc.omo/resolver/index.ts +19 -0
  49. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approval.ts +179 -0
  50. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalGroup.ts +116 -0
  51. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalState.ts +34 -0
  52. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.boulder.ts +503 -0
  53. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.config.ts +40 -0
  54. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.doc.ts +269 -0
  55. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.draftFile.ts +18 -0
  56. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.notepadsFile.ts +18 -0
  57. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.plan.ts +155 -0
  58. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.planFile.ts +361 -0
  59. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.proofFile.ts +18 -0
  60. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.rulesFile.ts +11 -0
  61. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.runContinuationFile.ts +14 -0
  62. package/src/pware.oc.opencode/constants/index.ts +10 -0
  63. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.eventName.ts +15 -0
  64. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.fileTouch.ts +18 -0
  65. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.questionKind.ts +45 -0
  66. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.sessionStatus.ts +58 -0
  67. package/src/pware.oc.opencode/index.ts +8 -0
  68. package/src/pware.oc.opencode/pware.oc.opencode.events.ts +182 -0
  69. package/src/pware.oc.opencode/pware.oc.opencode.files.ts +399 -0
  70. package/src/pware.oc.opencode/resolver/index.ts +170 -0
  71. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.file.ts +162 -0
  72. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.question.ts +185 -0
  73. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.session.ts +211 -0
  74. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.todo.ts +26 -0
  75. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.tool.ts +236 -0
  76. package/src/pware.oc.perf/index.ts +14 -0
  77. package/src/pware.oc.perf/pware.oc.perf.asciichart.d.ts +15 -0
  78. package/src/pware.oc.perf/pware.oc.perf.charts.ts +284 -0
  79. package/src/pware.oc.perf/pware.oc.perf.reader.ts +927 -0
  80. package/src/pware.oc.perf/pware.oc.perf.realtime.ts +88 -0
  81. package/src/pware.oc.perf/pware.oc.perf.realtimeBlock.ts +118 -0
  82. package/src/pware.oc.perf/pware.oc.perf.realtimeCpuRam.ts +79 -0
  83. package/src/pware.oc.perf/pware.oc.perf.realtimeSampler.ts +71 -0
  84. package/src/pware.oc.perf/pware.oc.perf.realtimeTimeline.ts +214 -0
  85. package/src/pware.oc.perf/pware.oc.perf.self.ts +179 -0
  86. package/src/pware.oc.perf/pware.oc.perf.view.tsx +462 -0
  87. package/src/pware.oc.runtime/index.ts +13 -0
  88. package/src/pware.oc.runtime/pware.oc.runtime.monitor.ts +142 -0
  89. package/src/pware.oc.runtime/pware.oc.runtime.mywork-enrich.ts +79 -0
  90. package/src/pware.oc.runtime/pware.oc.runtime.mywork.ts +253 -0
  91. package/src/pware.oc.runtime/pware.oc.runtime.questions.ts +95 -0
  92. package/src/pware.oc.runtime/pware.oc.runtime.snapshotClient.ts +129 -0
  93. package/src/pware.oc.runtime/pware.oc.runtime.source.ts +105 -0
  94. package/src/pware.oc.runtime/pware.oc.runtime.worker.ts +43 -0
  95. package/src/pware.oc.runtime/resolver/index.ts +224 -0
  96. package/src/pware.oc.runtime/resolver/pware.oc.runtime.resolver.delegate.ts +146 -0
  97. package/src/pware.oc.ui/index.ts +12 -0
  98. package/src/pware.oc.ui/pware.oc.ui.chrome.tsx +339 -0
  99. package/src/pware.oc.ui/pware.oc.ui.glyphs.tsx +144 -0
  100. package/src/pware.oc.ui/pware.oc.ui.host.tsx +111 -0
  101. package/src/pware.oc.ui/pware.oc.ui.live.tsx +82 -0
  102. package/src/pware.oc.ui/pware.oc.ui.menudialogs.tsx +843 -0
  103. package/src/pware.oc.ui/pware.oc.ui.sections.tsx +526 -0
  104. package/src/pware.oc.ui/pware.oc.ui.sidebar.tsx +1629 -0
  105. package/src/pware.oc.ui.tsx +98 -0
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Watch boulder.json + poll SQLite stamps. Invokes onChange when fingerprint changes.
3
+ */
4
+ import fs from "node:fs"
5
+ import path from "node:path"
6
+ import type { PwareEvent } from "../pware.oc.core/pware.oc.core.bus.js"
7
+ import { EV_OES_SNAPSHOT } from "../pware.oc.core/constants/pware.oc.core.constants.eventName.js"
8
+ import { EV_OMO_BOULDER_CHANGED } from "../pware.oc.omo/constants/pware.oc.omo.constants.eventName.js"
9
+ import { findBoulder } from "../pware.oc.omo/resolver/index.js"
10
+ import { computeFingerprint, type RuntimeSnapshot } from "./resolver/index.js"
11
+ import { readRuntimeSnapshotAsync } from "./pware.oc.runtime.snapshotClient.js"
12
+ import { getOpenCodeDbPath } from "../pware.oc.core/pware.oc.core.paths.js"
13
+ import { MONITOR_POLL_MS, MONITOR_WATCH_DEBOUNCE_MS } from "../pware.oc.core/pware.oc.core.timing.js"
14
+ import { dbg, profile } from "../pware.oc.core/pware.oc.core.debug.js"
15
+
16
+ export type MonitorOptions = {
17
+ sessionId: string
18
+ projectRoot: string | null
19
+ dbPath?: string
20
+ pollMs?: number
21
+ onChange?: (snap: RuntimeSnapshot) => void
22
+ emit?: (evt: PwareEvent) => void
23
+ }
24
+
25
+ export type MonitorHandle = {
26
+ stop: () => void
27
+ refresh: () => void
28
+ question: (sessionId: string) => void
29
+ }
30
+
31
+ export function startMonitor(opts: MonitorOptions): MonitorHandle {
32
+ const dbPath = opts.dbPath || getOpenCodeDbPath(process.env, undefined, opts.projectRoot)
33
+ dbg("monitor", "start", { dbPath, sessionId: opts.sessionId, projectRoot: opts.projectRoot ?? null })
34
+ const pollMs = opts.pollMs ?? MONITOR_POLL_MS
35
+ let lastFp = ""
36
+ let stopped = false
37
+ let emitGen = 0
38
+ let debounce: ReturnType<typeof setTimeout> | null = null
39
+ let pendingQuestionHint: string | null = null
40
+ const watchers: fs.FSWatcher[] = []
41
+
42
+ const emit = (force = false) =>
43
+ profile("monitor.emit", () => {
44
+ if (stopped) return
45
+ const fp = computeFingerprint({
46
+ dbPath,
47
+ projectRoot: opts.projectRoot,
48
+ sessionId: opts.sessionId,
49
+ })
50
+ if (!force && fp === lastFp) return
51
+ dbg("monitor", "emit", { force, fp: fp.slice(0, 40) })
52
+ lastFp = fp
53
+ const gen = ++emitGen
54
+ const questionHint = pendingQuestionHint ?? undefined
55
+ pendingQuestionHint = null
56
+ void readRuntimeSnapshotAsync({
57
+ sessionId: opts.sessionId,
58
+ projectRoot: opts.projectRoot,
59
+ dbPath,
60
+ questionHint,
61
+ }).then((snapshot) => {
62
+ if (stopped || gen !== emitGen) return
63
+ opts.onChange?.(snapshot)
64
+ opts.emit?.({
65
+ type: EV_OES_SNAPSHOT,
66
+ ts: Date.now(),
67
+ data: { snapshot },
68
+ })
69
+ })
70
+ })
71
+
72
+ const schedule = () => {
73
+ if (stopped) return
74
+ if (debounce) clearTimeout(debounce)
75
+ debounce = setTimeout(() => {
76
+ debounce = null
77
+ emit()
78
+ }, MONITOR_WATCH_DEBOUNCE_MS)
79
+ }
80
+
81
+ // Initial
82
+ emit(true)
83
+
84
+ const interval = setInterval(schedule, pollMs)
85
+
86
+ const root = opts.projectRoot
87
+ if (root) {
88
+ const boulderPath = findBoulder(root)
89
+ if (boulderPath) {
90
+ try {
91
+ watchers.push(
92
+ fs.watch(boulderPath, { persistent: false }, () => {
93
+ opts.emit?.({
94
+ type: EV_OMO_BOULDER_CHANGED,
95
+ ts: Date.now(),
96
+ data: {},
97
+ })
98
+ schedule()
99
+ }),
100
+ )
101
+ } catch {
102
+ // poll covers it
103
+ }
104
+ }
105
+ }
106
+
107
+ // SQLite: watching the file is flaky under WAL — poll stamps instead.
108
+ // Still try a non-recursive watch on the data dir for companions.
109
+ try {
110
+ const dataDir = path.dirname(dbPath)
111
+ if (fs.existsSync(dataDir)) {
112
+ watchers.push(
113
+ fs.watch(dataDir, { persistent: false }, (event, filename) => {
114
+ const name = filename?.toString() || ""
115
+ if (!name || name.includes("opencode.db")) schedule()
116
+ }),
117
+ )
118
+ }
119
+ } catch {
120
+ // poll only
121
+ }
122
+
123
+ return {
124
+ refresh: () => emit(true),
125
+ question: (sessionId: string) => {
126
+ pendingQuestionHint = sessionId
127
+ emit(true)
128
+ },
129
+ stop: () => {
130
+ stopped = true
131
+ if (debounce) clearTimeout(debounce)
132
+ clearInterval(interval)
133
+ for (const w of watchers) {
134
+ try {
135
+ w.close()
136
+ } catch {
137
+ // ignore
138
+ }
139
+ }
140
+ },
141
+ }
142
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * pware.oc.runtime.mywork-enrich
3
+ *
4
+ * Enriches the OMO approval queue with the writer session's activity state:
5
+ * builds the plan-file → writer-session index once, then per item resolves the
6
+ * writer via `basenameOf(item.rel)` and asks `sessionActivityState` (SQLite
7
+ * freshness + the omo `.omo/run-continuation` marker). Soft-fails to
8
+ * `sessionState: null` per item — never throws. This is the runtime layer's
9
+ * job: it composes omo (`.omo/run-continuation`) with opencode (SQLite session
10
+ * activity).
11
+ */
12
+ import fs from "node:fs"
13
+ import {
14
+ sessionActivityState,
15
+ type SessionActivityState,
16
+ } from "../pware.oc.opencode/resolver/pware.oc.opencode.resolver.session.js"
17
+ import { listTodos } from "../pware.oc.opencode/resolver/pware.oc.opencode.resolver.todo.js"
18
+ import { planSessionIndex } from "../pware.oc.omo/resolver/index.js"
19
+ import { openReadonlyDb, withDbRead, type SqlDb } from "../pware.oc.core/pware.oc.core.sqlite.js"
20
+ import { profile } from "../pware.oc.core/pware.oc.core.debug.js"
21
+ import { basenameOf } from "../pware.oc.core/pware.oc.core.paths.js"
22
+ import { readRunContinuationState } from "../pware.oc.omo/resolver/pware.oc.omo.resolver.approvalState.js"
23
+ import { BACKGROUND_TASK_ACTIVE } from "../pware.oc.omo/constants/pware.oc.omo.constants.backgroundTask.js"
24
+ import { STATUS_COMPLETED } from "../pware.oc.core/constants/pware.oc.core.constants.status.js"
25
+ import type { ApprovalItem } from "../pware.oc.omo/resolver/pware.oc.omo.resolver.plan.js"
26
+
27
+ /**
28
+ * An approval item with its planner-session activity attached. `sessionState`
29
+ * is a runtime enrichment: it lives here on the runtime layer's enriched type,
30
+ * not on omo's base `ApprovalItem` (omo must not import the opencode session type).
31
+ */
32
+ export type EnrichedApproval = ApprovalItem & {
33
+ sessionState: SessionActivityState | null
34
+ /** Writer session's todos all completed (and at least one present). */
35
+ todosDone: boolean
36
+ }
37
+
38
+ function blank(items: readonly ApprovalItem[]): EnrichedApproval[] {
39
+ return items.map((item) => ({ ...item, sessionState: null, todosDone: false }))
40
+ }
41
+
42
+ function sessionTodosDone(db: SqlDb, sessionId: string): boolean {
43
+ const todos = listTodos(db, sessionId)
44
+ return todos.length > 0 && todos.every((t) => t.status === STATUS_COMPLETED)
45
+ }
46
+
47
+ export function enrichApprovalSessionStates(
48
+ items: readonly ApprovalItem[],
49
+ opts: { dbPath: string | null | undefined; projectRoot: string | null | undefined; now?: number },
50
+ ): EnrichedApproval[] {
51
+ if (items.length === 0) return []
52
+ const dbPath = opts.dbPath
53
+ const projectRoot = opts.projectRoot
54
+ if (!dbPath || !projectRoot || !fs.existsSync(dbPath)) return blank(items)
55
+ return profile("mywork.approvals", () =>
56
+ withDbRead(
57
+ () => {
58
+ const db = openReadonlyDb(dbPath)
59
+ if (!db) return blank(items)
60
+ // Index once; match writers by basename across all sessions (no
61
+ // projectId in the opts shape — same scope as sessionForPlanFile).
62
+ const index = planSessionIndex(db, null, projectRoot)
63
+ return items.map((item) => {
64
+ const sessionId = index.fileWriter.get(basenameOf(item.rel))?.sessionId ?? null
65
+ const sessionState = sessionId
66
+ ? sessionActivityState(db, sessionId, {
67
+ backgroundTaskActive:
68
+ readRunContinuationState(projectRoot, sessionId) === BACKGROUND_TASK_ACTIVE,
69
+ now: opts.now,
70
+ })
71
+ : null
72
+ const todosDone = sessionId ? sessionTodosDone(db, sessionId) : false
73
+ return { ...item, sessionState, todosDone }
74
+ })
75
+ },
76
+ () => blank(items),
77
+ ),
78
+ )
79
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * pware.oc.core.mywork.resolver
3
+ *
4
+ * The "My work" queue — one list of things awaiting the user's action, from
5
+ * two sources: an open OpenCode `question` (core, always) and an OMO plan
6
+ * awaiting approval (optional, only when `.omo/` is present).
7
+ */
8
+ import type { OpenQuestion } from "../pware.oc.opencode/resolver/pware.oc.opencode.resolver.question.js"
9
+ import type {
10
+ AgentStatus,
11
+ SessionActivityState,
12
+ } from "../pware.oc.opencode/resolver/pware.oc.opencode.resolver.session.js"
13
+ import type { ReviewState } from "../pware.oc.omo/resolver/pware.oc.omo.resolver.plan.js"
14
+ import type { EnrichedApproval } from "./pware.oc.runtime.mywork-enrich.js"
15
+ import {
16
+ MY_WORK_GROUP_DISMISSED,
17
+ MY_WORK_GROUP_DRAFT_DOCS,
18
+ MY_WORK_GROUP_DRAFTING,
19
+ MY_WORK_GROUP_FINISHED,
20
+ MY_WORK_GROUP_PINNED,
21
+ MY_WORK_GROUP_PLANS,
22
+ MY_WORK_GROUP_READY_REVIEW,
23
+ MY_WORK_GROUP_READY_START,
24
+ MY_WORK_GROUP_SESSIONS,
25
+ type ApprovalGroupKind,
26
+ } from "../pware.oc.core/constants/pware.oc.core.constants.myWork.js"
27
+ import { isDraftOf, resolveApprovalGroup } from "../pware.oc.omo/resolver/pware.oc.omo.resolver.approvalGroup.js"
28
+ import {
29
+ QUESTION_KIND_ERROR,
30
+ QUESTION_KIND_INTERRUPTED,
31
+ QUESTION_KIND_QUESTION,
32
+ type OpenQuestionKind,
33
+ } from "../pware.oc.opencode/constants/pware.oc.opencode.constants.questionKind.js"
34
+ import {
35
+ START_WORK_MAKE_PR,
36
+ START_WORK_PLAIN,
37
+ START_WORK_SHIP,
38
+ type StartWorkMode,
39
+ } from "../pware.oc.omo/constants/pware.oc.omo.constants.startWork.js"
40
+
41
+ export type { ApprovalGroupKind, OpenQuestionKind, StartWorkMode }
42
+
43
+ export type QuestionGroupKind = OpenQuestionKind | typeof MY_WORK_GROUP_DISMISSED
44
+
45
+ export type MyWorkItem =
46
+ | {
47
+ kind: QuestionGroupKind
48
+ partId: string
49
+ sessionId: string
50
+ title: string
51
+ startedAt: number | null
52
+ /** `state.error` text for interrupted/error rows; null for an open question. */
53
+ reason: string | null
54
+ }
55
+ | {
56
+ kind: ApprovalGroupKind
57
+ name: string
58
+ rel: string
59
+ pendingAction: string | null
60
+ updatedAt: number | null
61
+ sessionState: SessionActivityState | null
62
+ review: ReviewState | null
63
+ }
64
+ | {
65
+ kind: typeof MY_WORK_GROUP_SESSIONS
66
+ sessionId: string
67
+ title: string
68
+ status: AgentStatus
69
+ timeUpdated: number | null
70
+ }
71
+ | {
72
+ kind: typeof MY_WORK_GROUP_PINNED
73
+ sessionId: string
74
+ title: string
75
+ status: AgentStatus
76
+ timeUpdated: number | null
77
+ }
78
+ | {
79
+ kind: typeof MY_WORK_GROUP_DRAFT_DOCS
80
+ name: string
81
+ rel: string
82
+ updatedAt: number | null
83
+ }
84
+ | {
85
+ kind: typeof MY_WORK_GROUP_PLANS
86
+ name: string
87
+ rel: string
88
+ updatedAt: number | null
89
+ }
90
+
91
+ export type MyWorkKind = MyWorkItem["kind"]
92
+
93
+ export const MY_WORK_ORDER: readonly MyWorkKind[] = [
94
+ MY_WORK_GROUP_PINNED,
95
+ QUESTION_KIND_QUESTION,
96
+ QUESTION_KIND_INTERRUPTED,
97
+ QUESTION_KIND_ERROR,
98
+ MY_WORK_GROUP_SESSIONS,
99
+ MY_WORK_GROUP_READY_REVIEW,
100
+ MY_WORK_GROUP_READY_START,
101
+ MY_WORK_GROUP_FINISHED,
102
+ MY_WORK_GROUP_DRAFTING,
103
+ MY_WORK_GROUP_DRAFT_DOCS,
104
+ MY_WORK_GROUP_PLANS,
105
+ MY_WORK_GROUP_DISMISSED,
106
+ ]
107
+
108
+ const MY_WORK_LABELS: Record<MyWorkKind, string> = {
109
+ [MY_WORK_GROUP_PINNED]: "Pinned",
110
+ [QUESTION_KIND_QUESTION]: "Awaiting answer",
111
+ [QUESTION_KIND_INTERRUPTED]: "Interrupted",
112
+ [QUESTION_KIND_ERROR]: "Errors",
113
+ [MY_WORK_GROUP_SESSIONS]: "Sessions",
114
+ [MY_WORK_GROUP_READY_REVIEW]: "Ready to review",
115
+ [MY_WORK_GROUP_READY_START]: "Ready to start",
116
+ [MY_WORK_GROUP_FINISHED]: "Finished",
117
+ [MY_WORK_GROUP_DISMISSED]: "Dismissed questions",
118
+ [MY_WORK_GROUP_DRAFTING]: "Drafting",
119
+ [MY_WORK_GROUP_DRAFT_DOCS]: "Draft docs",
120
+ [MY_WORK_GROUP_PLANS]: "Plans",
121
+ }
122
+
123
+ export function myWorkLabel(kind: MyWorkKind): string {
124
+ return MY_WORK_LABELS[kind]
125
+ }
126
+
127
+ /**
128
+ * Why "Navigate to session" is unavailable for an approval: no writer session
129
+ * was found in the database, or the database itself could not be opened. Null
130
+ * when navigation is actionable.
131
+ */
132
+ export function approvalContinueHint(
133
+ sessionId: string | null | undefined,
134
+ dbAvailable: boolean,
135
+ ): string | null {
136
+ if (sessionId) return null
137
+ return dbAvailable ? "No session wrote this plan" : "Database unavailable"
138
+ }
139
+
140
+ /** The exact "start work" command text for a delivery mode and optional plan name. */
141
+ export function startWorkCommand(mode: StartWorkMode, planName?: string | null): string {
142
+ const name = typeof planName === "string" ? planName.trim() : ""
143
+ const base = name ? `start work ${name}` : "start work"
144
+ if (mode === START_WORK_MAKE_PR) return `${base} --make-pr`
145
+ if (mode === START_WORK_SHIP) return `${base} --ship`
146
+ return base
147
+ }
148
+
149
+ export function groupMyWork(
150
+ items: readonly MyWorkItem[],
151
+ ): { kind: MyWorkKind; items: MyWorkItem[] }[] {
152
+ const out: { kind: MyWorkKind; items: MyWorkItem[] }[] = []
153
+ for (const kind of MY_WORK_ORDER) {
154
+ const bucket = items.filter((i) => i.kind === kind)
155
+ if (bucket.length > 0) out.push({ kind, items: bucket })
156
+ }
157
+ return out
158
+ }
159
+
160
+ /** Build the question items from an open-question read (title comes from the row). */
161
+ export function toQuestionItems(questions: readonly OpenQuestion[]): MyWorkItem[] {
162
+ return questions.map((q) => ({
163
+ kind: q.kind,
164
+ partId: q.partId,
165
+ sessionId: q.sessionId,
166
+ title: q.title,
167
+ startedAt: q.startedAt,
168
+ reason: q.reason,
169
+ }))
170
+ }
171
+
172
+ /** Build the approval items from a pending-approval read, dropping superseded plans. */
173
+ export function toApprovalItems(approvals: readonly EnrichedApproval[]): MyWorkItem[] {
174
+ const out: MyWorkItem[] = []
175
+ for (const a of approvals) {
176
+ const kind = resolveApprovalGroup(a.status, isDraftOf(a.rel), a.workState, a.todosDone)
177
+ if (!kind) continue
178
+ out.push({
179
+ kind,
180
+ name: a.name,
181
+ rel: a.rel,
182
+ pendingAction: a.pendingAction,
183
+ updatedAt: a.updatedAt,
184
+ sessionState: a.sessionState,
185
+ review: a.review,
186
+ })
187
+ }
188
+ return out
189
+ }
190
+
191
+ /** Build the Draft docs items — drafts no action group covers, browsed as files. */
192
+ export function toDraftDocItems(
193
+ docs: readonly { name: string; rel: string; updatedAt: number | null }[],
194
+ ): MyWorkItem[] {
195
+ return docs.map((d) => ({ kind: MY_WORK_GROUP_DRAFT_DOCS, name: d.name, rel: d.rel, updatedAt: d.updatedAt }))
196
+ }
197
+
198
+ /** Build the Plans items — plan files no action group covers, browsed as files. */
199
+ export function toPlanItems(
200
+ docs: readonly { name: string; rel: string; updatedAt: number | null }[],
201
+ ): MyWorkItem[] {
202
+ return docs.map((d) => ({ kind: MY_WORK_GROUP_PLANS, name: d.name, rel: d.rel, updatedAt: d.updatedAt }))
203
+ }
204
+
205
+ /** Build the sessions group from recent main sessions — every session, live or idle. */
206
+ export function toSessionItems(
207
+ sessions: readonly {
208
+ id: string
209
+ title: string
210
+ status: AgentStatus
211
+ timeUpdated: number | null
212
+ }[],
213
+ ): MyWorkItem[] {
214
+ return sessions.map((s) => ({
215
+ kind: MY_WORK_GROUP_SESSIONS,
216
+ sessionId: s.id,
217
+ title: s.title,
218
+ status: s.status,
219
+ timeUpdated: s.timeUpdated,
220
+ }))
221
+ }
222
+
223
+ /** Parse the persisted dismissed-question set (a JSON array of part ids) from the kv store. */
224
+ export function parseDismissed(raw: string | null | undefined): ReadonlySet<string> {
225
+ if (!raw) return new Set()
226
+ try {
227
+ const value = JSON.parse(raw)
228
+ if (!Array.isArray(value)) return new Set()
229
+ return new Set(value.filter((x): x is string => typeof x === "string" && x.length > 0))
230
+ } catch {
231
+ return new Set()
232
+ }
233
+ }
234
+
235
+ /** Serialize a dismissed-question id set back to the JSON string the kv store holds. */
236
+ export function formatDismissed(ids: ReadonlySet<string>): string {
237
+ return JSON.stringify([...ids])
238
+ }
239
+
240
+ export function dropDismissed(
241
+ items: readonly MyWorkItem[],
242
+ dismissed: ReadonlySet<string>,
243
+ ): MyWorkItem[] {
244
+ return items.map((i) => {
245
+ if (!("partId" in i)) return i
246
+ const byId = dismissed.has(i.partId)
247
+ const byReason = i.kind === QUESTION_KIND_ERROR
248
+ && typeof i.reason === "string"
249
+ && i.reason.toLowerCase().includes("dismissed this question")
250
+ if (!byId && !byReason) return i
251
+ return { ...i, kind: MY_WORK_GROUP_DISMISSED }
252
+ })
253
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * pware.oc.runtime.questions
3
+ *
4
+ * In-memory, per-session cache of open questions for the "My work" queue. A
5
+ * later wiring task feeds it from cheap host-event hints (targeted
6
+ * `listSessionQuestions` on the session a `message.part.updated` just touched)
7
+ * and a periodic `listOpenQuestions` backstop, replacing the expensive
8
+ * project-wide scan that used to run on every snapshot.
9
+ *
10
+ * `createQuestionCache()` returns a fresh instance — no module-level mutable
11
+ * singleton — so tests build their own and the wiring task owns the one real
12
+ * instance. State is `bySession`, a Map keyed by `sessionId`; `get` flattens
13
+ * and dedupes it. The two read functions below already soft-fail to `[]`, so
14
+ * this cache only holds/merges their results and needs no try/catch of its own.
15
+ */
16
+ import {
17
+ listOpenQuestions,
18
+ listSessionQuestions,
19
+ type OpenQuestion,
20
+ } from "../pware.oc.opencode/resolver/pware.oc.opencode.resolver.question.js"
21
+
22
+ export type QuestionCache = {
23
+ /** Project-wide open questions, deduped by partId, sorted `startedAt` DESC (nulls last). */
24
+ get: () => OpenQuestion[]
25
+ /** Full `listOpenQuestions` scan; replaces the whole map (drop gone sessions). */
26
+ seed: (dbPath: string, projectId: string | null) => void
27
+ /** One session's `listSessionQuestions`; merges in, or removes on empty. */
28
+ touch: (dbPath: string, projectId: string | null, sessionId: string) => void
29
+ /** Full `listOpenQuestions` scan — semantic alias of `seed` for the backstop call site. */
30
+ reconcile: (dbPath: string, projectId: string | null) => void
31
+ /** Drop every cached session. */
32
+ reset: () => void
33
+ }
34
+
35
+ /**
36
+ * Flatten all per-session slices, dedupe by `partId` (a question may briefly
37
+ * appear in two sessions' slices — keep the first), and sort by `startedAt`
38
+ * descending with nulls last. Pure — never mutates its input.
39
+ */
40
+ export function mergeQuestions(
41
+ bySession: ReadonlyMap<string, readonly OpenQuestion[]>,
42
+ ): OpenQuestion[] {
43
+ const seen = new Set<string>()
44
+ const out: OpenQuestion[] = []
45
+ for (const list of bySession.values()) {
46
+ for (const q of list) {
47
+ if (seen.has(q.partId)) continue
48
+ seen.add(q.partId)
49
+ out.push(q)
50
+ }
51
+ }
52
+ out.sort((a, b) => {
53
+ const x = a.startedAt
54
+ const y = b.startedAt
55
+ if (x === y) return 0
56
+ if (x == null) return 1
57
+ if (y == null) return -1
58
+ return y - x
59
+ })
60
+ return out
61
+ }
62
+
63
+ export function createQuestionCache(): QuestionCache {
64
+ let bySession = new Map<string, OpenQuestion[]>()
65
+
66
+ function refill(dbPath: string, projectId: string | null): void {
67
+ const next = new Map<string, OpenQuestion[]>()
68
+ for (const q of listOpenQuestions({ dbPath, projectId })) {
69
+ const bucket = next.get(q.sessionId)
70
+ if (bucket) bucket.push(q)
71
+ else next.set(q.sessionId, [q])
72
+ }
73
+ bySession = next
74
+ }
75
+
76
+ return {
77
+ get() {
78
+ return mergeQuestions(bySession)
79
+ },
80
+ seed(dbPath, projectId) {
81
+ refill(dbPath, projectId)
82
+ },
83
+ touch(dbPath, projectId, sessionId) {
84
+ const result = listSessionQuestions({ dbPath, sessionId, projectId })
85
+ if (result.length > 0) bySession.set(sessionId, result)
86
+ else bySession.delete(sessionId)
87
+ },
88
+ reconcile(dbPath, projectId) {
89
+ refill(dbPath, projectId)
90
+ },
91
+ reset() {
92
+ bySession = new Map()
93
+ },
94
+ }
95
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * pware.oc.runtime.snapshotClient
3
+ *
4
+ * Async facade over the snapshot worker. `readRuntimeSnapshotAsync` posts a
5
+ * snapshot request and resolves with the composed snapshot; when the worker
6
+ * cannot be spawned, errors, or fails to answer within the timeout it falls
7
+ * back to the synchronous `readRuntimeSnapshot` — so the panel never hard
8
+ * depends on worker availability.
9
+ */
10
+ import { dbg } from "../pware.oc.core/pware.oc.core.debug.js"
11
+ import { readRuntimeSnapshot, type RuntimeSnapshot } from "./resolver/index.js"
12
+
13
+ export type SnapshotRequestOpts = {
14
+ sessionId: string
15
+ projectRoot: string | null
16
+ dbPath?: string
17
+ questionHint?: string
18
+ }
19
+
20
+ type SnapshotDoneMessage = {
21
+ type: "snapshot:done"
22
+ id: number
23
+ ok: boolean
24
+ snap?: RuntimeSnapshot
25
+ error?: string
26
+ }
27
+
28
+ /** Bound on a single worker round-trip before the sync path takes over. */
29
+ const WORKER_TIMEOUT_MS = 4_000
30
+
31
+ type PendingEntry = {
32
+ opts: SnapshotRequestOpts
33
+ resolve: (snap: RuntimeSnapshot) => void
34
+ timer: ReturnType<typeof setTimeout>
35
+ }
36
+
37
+ let worker: Worker | null = null
38
+ let workerFailed = false
39
+ let nextId = 0
40
+ const pending = new Map<number, PendingEntry>()
41
+
42
+ function syncFallback(opts: SnapshotRequestOpts): RuntimeSnapshot {
43
+ return readRuntimeSnapshot(opts)
44
+ }
45
+
46
+ function settle(id: number, snap: RuntimeSnapshot): void {
47
+ const entry = pending.get(id)
48
+ if (!entry) return
49
+ pending.delete(id)
50
+ clearTimeout(entry.timer)
51
+ entry.resolve(snap)
52
+ }
53
+
54
+ function failAll(reason: string): void {
55
+ dbg("snapshot.worker", "failed", { reason })
56
+ workerFailed = true
57
+ for (const id of [...pending.keys()]) {
58
+ const entry = pending.get(id)
59
+ if (entry) settle(id, syncFallback(entry.opts))
60
+ }
61
+ }
62
+
63
+ function ensureWorker(): Worker | null {
64
+ if (workerFailed) return null
65
+ if (worker) return worker
66
+ try {
67
+ const w = new Worker(new URL("./pware.oc.runtime.worker.ts", import.meta.url), { type: "module" })
68
+ w.onmessage = (event: MessageEvent<SnapshotDoneMessage>) => {
69
+ const msg = event.data
70
+ if (!msg || msg.type !== "snapshot:done") return
71
+ const entry = pending.get(msg.id)
72
+ if (!entry) return
73
+ settle(msg.id, msg.ok && msg.snap ? msg.snap : syncFallback(entry.opts))
74
+ }
75
+ w.onerror = (event) => {
76
+ const message =
77
+ typeof event === "object" && event !== null && "message" in event
78
+ ? String((event as { message: unknown }).message)
79
+ : String(event)
80
+ failAll(message)
81
+ try {
82
+ w.terminate()
83
+ } catch {
84
+ // ignore
85
+ }
86
+ worker = null
87
+ }
88
+ worker = w
89
+ dbg("snapshot.worker", "spawned", {})
90
+ return w
91
+ } catch (err) {
92
+ dbg("snapshot.worker", "spawn failed", { error: err instanceof Error ? err.message : String(err) })
93
+ workerFailed = true
94
+ return null
95
+ }
96
+ }
97
+
98
+ /** Off-main-thread snapshot read, falling back to the sync path on any failure. */
99
+ export function readRuntimeSnapshotAsync(opts: SnapshotRequestOpts): Promise<RuntimeSnapshot> {
100
+ const w = ensureWorker()
101
+ if (!w) return Promise.resolve(syncFallback(opts))
102
+ const id = ++nextId
103
+ return new Promise<RuntimeSnapshot>((resolve) => {
104
+ const timer = setTimeout(() => settle(id, syncFallback(opts)), WORKER_TIMEOUT_MS)
105
+ pending.set(id, { opts, resolve, timer })
106
+ try {
107
+ w.postMessage({ type: "snapshot", id, opts })
108
+ } catch (err) {
109
+ dbg("snapshot.worker", "post failed", { error: err instanceof Error ? err.message : String(err) })
110
+ settle(id, syncFallback(opts))
111
+ }
112
+ })
113
+ }
114
+
115
+ /** Terminate the shared worker (idempotent). Safe to call on plugin shutdown. */
116
+ export function shutdownSnapshotWorker(): void {
117
+ if (!worker) return
118
+ try {
119
+ worker.postMessage({ type: "shutdown" })
120
+ } catch {
121
+ // ignore
122
+ }
123
+ try {
124
+ worker.terminate()
125
+ } catch {
126
+ // ignore
127
+ }
128
+ worker = null
129
+ }