ompchamber 3.0.0 → 3.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.
Files changed (100) hide show
  1. package/README.md +24 -5
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +333 -333
  4. package/dist/client/static/js/{index-prbmx3yj.js → index-8t70jhyj.js} +643 -617
  5. package/dist/client/static/js/index-n4wkqj7t.css +1 -0
  6. package/package.json +1 -1
  7. package/src/client/components/common/code-editor/handler.test.ts +274 -0
  8. package/src/client/components/common/code-editor/handler.ts +175 -0
  9. package/src/client/components/common/code-editor/index.tsx +185 -220
  10. package/src/client/components/common/code-surface/index.tsx +109 -12
  11. package/src/client/components/common/lazy-panels.tsx +5 -1
  12. package/src/client/components/common/viewport.ts +5 -3
  13. package/src/client/components/layout/RightActivityBar.tsx +2 -1
  14. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +21 -4
  15. package/src/client/components/layout/desktop-layout/index.tsx +24 -4
  16. package/src/client/components/mobile/RightSidebar.tsx +11 -4
  17. package/src/client/components/workspace/browser-panel/AddressBar.tsx +1 -10
  18. package/src/client/components/workspace/browser-panel/Screencast.tsx +4 -5
  19. package/src/client/components/workspace/browser-panel/index.tsx +6 -6
  20. package/src/client/components/workspace/browser-toolbar/index.tsx +22 -12
  21. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +1 -1
  22. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Todo.tsx +1 -1
  23. package/src/client/components/workspace/editor/CommandPalette.test.ts +242 -0
  24. package/src/client/components/workspace/editor/CommandPalette.tsx +138 -0
  25. package/src/client/components/workspace/editor/FindWidget.test.ts +261 -0
  26. package/src/client/components/workspace/editor/FindWidget.tsx +219 -0
  27. package/src/client/components/workspace/editor/Toolbar.tsx +19 -1
  28. package/src/client/components/workspace/editor/index.tsx +153 -53
  29. package/src/client/components/workspace/file-explorer/Modals.tsx +79 -2
  30. package/src/client/components/workspace/file-explorer/TreeItem.tsx +43 -113
  31. package/src/client/components/workspace/todo-panel/PhaseGroup.tsx +76 -0
  32. package/src/client/components/workspace/todo-panel/Row.tsx +69 -0
  33. package/src/client/components/workspace/todo-panel/index.tsx +161 -0
  34. package/src/client/hooks/editor/find-harness.test-util.ts +170 -0
  35. package/src/client/hooks/editor/scroll-container.test.ts +102 -0
  36. package/src/client/hooks/editor/scroll-container.ts +26 -0
  37. package/src/client/hooks/editor/use-code-window.ts +2 -20
  38. package/src/client/hooks/editor/use-editor-find-write.test.ts +170 -0
  39. package/src/client/hooks/editor/use-editor-find.test.ts +118 -0
  40. package/src/client/hooks/editor/use-editor-find.ts +321 -0
  41. package/src/client/hooks/editor/use-editor-history.test.ts +216 -0
  42. package/src/client/hooks/editor/use-editor-history.ts +134 -0
  43. package/src/client/hooks/editor/use-occurrences.test.ts +236 -0
  44. package/src/client/hooks/editor/use-occurrences.ts +98 -0
  45. package/src/client/hooks/workspace/file-tree-actions.test.ts +348 -0
  46. package/src/client/hooks/workspace/file-tree-actions.ts +250 -0
  47. package/src/client/hooks/workspace/session-todos.ts +102 -0
  48. package/src/client/tailwind.css +30 -0
  49. package/src/server/lib/omp/session/todos.ts +100 -0
  50. package/src/server/routes/fs/action-create.test.ts +90 -0
  51. package/src/server/routes/fs/action.ts +23 -0
  52. package/src/server/routes/omp/index.ts +2 -0
  53. package/src/server/routes/omp/session-todos.ts +80 -0
  54. package/src/shared/lib/chat/{todo-parser.ts → todo/parser.ts} +17 -0
  55. package/src/shared/lib/chat/todo/progress.ts +97 -0
  56. package/src/shared/lib/chat/todo/snapshot.test.ts +156 -0
  57. package/src/shared/lib/chat/todo/snapshot.ts +215 -0
  58. package/src/shared/lib/code/editor/commands.test.ts +155 -0
  59. package/src/shared/lib/code/editor/commands.ts +260 -0
  60. package/src/shared/lib/code/editor/edits.test.ts +100 -0
  61. package/src/shared/lib/code/editor/edits.ts +122 -0
  62. package/src/shared/lib/code/editor/find-status.test.ts +50 -0
  63. package/src/shared/lib/code/editor/find-status.ts +36 -0
  64. package/src/shared/lib/code/editor/find.test.ts +203 -0
  65. package/src/shared/lib/code/editor/find.ts +224 -0
  66. package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.test.ts +1 -1
  67. package/src/shared/lib/code/editor/keymap.test.ts +109 -0
  68. package/src/shared/lib/code/editor/keymap.ts +151 -0
  69. package/src/shared/lib/code/editor/layers.test.ts +77 -0
  70. package/src/shared/lib/code/editor/layers.ts +67 -0
  71. package/src/shared/lib/code/editor/line-edits.test.ts +131 -0
  72. package/src/shared/lib/code/editor/line-edits.ts +159 -0
  73. package/src/shared/lib/code/editor/lines.test.ts +81 -0
  74. package/src/shared/lib/code/editor/lines.ts +83 -0
  75. package/src/shared/lib/code/editor/marks.test.ts +86 -0
  76. package/src/shared/lib/code/editor/marks.ts +106 -0
  77. package/src/shared/lib/code/editor/occurrences.test.ts +117 -0
  78. package/src/shared/lib/code/editor/occurrences.ts +111 -0
  79. package/src/shared/lib/code/editor/palette.test.ts +91 -0
  80. package/src/shared/lib/code/editor/palette.ts +92 -0
  81. package/src/shared/lib/code/editor/reset-css.test.ts +35 -0
  82. package/src/shared/lib/code/editor/reset-css.ts +32 -0
  83. package/src/shared/lib/code/editor/text-commands.test.ts +178 -0
  84. package/src/shared/lib/code/editor/text-commands.ts +154 -0
  85. package/src/shared/lib/code/syntax-highlight.test.ts +75 -0
  86. package/src/shared/lib/code/syntax-highlight.ts +29 -17
  87. package/src/shared/lib/code/windowed-highlight.ts +52 -1
  88. package/src/shared/lib/ui/key-binding.test.ts +115 -0
  89. package/src/shared/lib/ui/key-binding.ts +143 -0
  90. package/src/shared/lib/util/platform.test.ts +87 -0
  91. package/src/shared/lib/util/platform.ts +30 -0
  92. package/src/shared/lib/workspace/keymap.test.ts +82 -0
  93. package/src/shared/lib/workspace/keymap.ts +30 -0
  94. package/src/shared/lib/workspace/refresh-cadence.ts +9 -0
  95. package/src/shared/lib/workspace/right-panels.ts +8 -0
  96. package/src/shared/lib/workspace/session-state/store.ts +0 -1
  97. package/src/shared/types/index.ts +1 -0
  98. package/src/shared/types/todo.ts +108 -0
  99. package/dist/client/static/js/index-ranztes9.css +0 -1
  100. /package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.ts +0 -0
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * GET /api/omp/session-todos?sessionId=<uuid> — the session's live todo list.
8
+ *
9
+ * Read-only projection of oh-my-pi's own persisted `todo` state: the deepest
10
+ * committed snapshot on the session transcript's active branch. omp is the
11
+ * owner of this list and there is no second copy — see
12
+ * `@/server/lib/omp/session/todos`.
13
+ *
14
+ * MOCK mode answers a deterministic demo list so the right-panel view renders
15
+ * its full range of states (in progress, blocked, completed, abandoned) without
16
+ * an omp install.
17
+ */
18
+
19
+ import { json, NO_STORE_HEADERS } from '@/server/lib/remix-compat';
20
+ import type { LoaderFunctionArgs } from '@/server/lib/remix-compat';
21
+ import { isMockMode } from '@/server/mock.server';
22
+ import { readSessionTodos } from '@/server/lib/omp/session/todos';
23
+ import { todoProgress } from '@/shared/lib/chat/todo/snapshot';
24
+ import type { SessionTodosPayload, TodoPhase } from '@/shared/types/todo';
25
+
26
+ /** Demo list under MOCK=true: one phase of every status the panel must draw. */
27
+ const MOCK_PHASES: TodoPhase[] = [
28
+ {
29
+ name: 'Server data',
30
+ tasks: [
31
+ { content: 'Read the session transcript for the deepest todo snapshot', status: 'completed' },
32
+ { content: 'Serve the list over /api/omp/session-todos', status: 'in_progress' },
33
+ { content: 'Cache the parse by size + mtime', status: 'pending' },
34
+ { content: 'Wire the panel poll to omp:session-updated', status: 'blocked', blocker: 'waiting on the event name' },
35
+ ],
36
+ },
37
+ {
38
+ name: 'Panel',
39
+ tasks: [
40
+ { content: 'Draw phase groups with per-status glyphs', status: 'pending' },
41
+ { content: 'Collapse closed tasks behind a toggle', status: 'abandoned' },
42
+ ],
43
+ },
44
+ ];
45
+
46
+ export async function loader({ request }: LoaderFunctionArgs) {
47
+ const url = new URL(request.url);
48
+ const sessionId = url.searchParams.get('sessionId');
49
+
50
+ if (isMockMode()) {
51
+ return json(
52
+ {
53
+ sessionId: sessionId ?? 'mock',
54
+ snapshot: {
55
+ phases: MOCK_PHASES,
56
+ sourceEntryId: 'mock-entry',
57
+ source: 'toolResult',
58
+ updatedAt: new Date().toISOString(),
59
+ op: 'start',
60
+ storage: 'session',
61
+ },
62
+ progress: todoProgress(MOCK_PHASES),
63
+ generatedAt: new Date().toISOString(),
64
+ isMock: true,
65
+ } satisfies SessionTodosPayload,
66
+ { headers: NO_STORE_HEADERS },
67
+ );
68
+ }
69
+
70
+ if (!sessionId) return json({ error: 'sessionId is required' }, { status: 400 });
71
+
72
+ const state = await readSessionTodos(sessionId);
73
+ const payload: SessionTodosPayload = {
74
+ sessionId,
75
+ ...state,
76
+ generatedAt: new Date().toISOString(),
77
+ isMock: false,
78
+ };
79
+ return json(payload, { headers: NO_STORE_HEADERS });
80
+ }
@@ -1,3 +1,20 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Renderer-side parser for one `todo` tool call: reads the human-readable
8
+ * checklist omp returns as the tool's text output (and the `list` argument as a
9
+ * fallback) into phase groups for the chat timeline's tool card.
10
+ *
11
+ * This is NOT the authoritative list — a tool card shows what that ONE call
12
+ * did, while the session's live list is the deepest committed snapshot on the
13
+ * active branch (`./snapshot.ts`, surfaced by the right-panel Todo view). The
14
+ * two must not be merged: a card that displayed the session-wide list would
15
+ * attribute every other call's work to this one.
16
+ */
17
+
1
18
  import type { ToolCallData } from '@/shared/types';
2
19
 
3
20
  export interface ParsedTask {
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Display-side helpers for a todo list: the one-line progress summary, the
8
+ * per-task visual state, and the "which task is the agent on" lookup the panel
9
+ * highlights.
10
+ *
11
+ * Everything here is presentational — the parse itself lives in `snapshot.ts`.
12
+ * The status → presentation mapping is a table rather than a chain of `if`s
13
+ * because there are five statuses and three consumers (icon, label, class
14
+ * list), and a missing case in any one of them is how a `blocked` task ends up
15
+ * rendered as a plain pending one.
16
+ */
17
+
18
+ import type { TodoItem, TodoPhase, TodoProgress, TodoStatus } from '@/shared/types/todo';
19
+
20
+ export interface TodoStatusVisual {
21
+ /** Short label for the row's status pill. */
22
+ label: string;
23
+ /** Tailwind text color for the icon and label. */
24
+ tone: string;
25
+ /** True when the row is a live (in-progress) task — highlighted, not dimmed. */
26
+ active: boolean;
27
+ /** True when the task is out of the way (completed / abandoned). */
28
+ closed: boolean;
29
+ }
30
+
31
+ const STATUS_VISUALS: Record<TodoStatus, TodoStatusVisual> = {
32
+ pending: { label: 'Pending', tone: 'text-ink/45', active: false, closed: false },
33
+ in_progress: { label: 'In progress', tone: 'text-ink', active: true, closed: false },
34
+ completed: { label: 'Completed', tone: 'text-success', active: false, closed: true },
35
+ abandoned: { label: 'Abandoned', tone: 'text-error/70', active: false, closed: true },
36
+ blocked: { label: 'Blocked', tone: 'text-warning', active: false, closed: false },
37
+ };
38
+
39
+ export function todoStatusVisual(status: TodoStatus): TodoStatusVisual {
40
+ return STATUS_VISUALS[status] ?? STATUS_VISUALS.pending;
41
+ }
42
+
43
+ /**
44
+ * One-line summary of a snapshot: `4/11 done · 1 in progress · 2 blocked`.
45
+ *
46
+ * Counts CLOSED (completed + abandoned) against the total, matching omp's own
47
+ * HUD counter — an abandoned task is deliberately not coming back, so leaving
48
+ * it in the denominator is what makes a list read as permanently stuck.
49
+ */
50
+ export function todoProgressLabel(progress: TodoProgress): string {
51
+ if (progress.total === 0) return 'No tasks';
52
+ const parts = [`${progress.closed}/${progress.total} done`];
53
+ if (progress.inProgress > 0) parts.push(`${progress.inProgress} in progress`);
54
+ if (progress.blocked > 0) parts.push(`${progress.blocked} blocked`);
55
+ if (progress.abandoned > 0) parts.push(`${progress.abandoned} abandoned`);
56
+ return parts.join(' · ');
57
+ }
58
+
59
+ /** Percentage of the list that is closed, rounded to a whole number. */
60
+ export function todoProgressPercent(progress: TodoProgress): number {
61
+ if (progress.total === 0) return 0;
62
+ return Math.round((progress.closed / progress.total) * 100);
63
+ }
64
+
65
+ /**
66
+ * The row the panel marks as current: the in-progress task, else the first
67
+ * pending one. Identified by phase index + task index rather than by content,
68
+ * because two phases may legitimately hold tasks with identical text (omp
69
+ * itself keys its own task lookup by content, and that ambiguity is its
70
+ * problem, not one to inherit here).
71
+ */
72
+ export function currentTaskLocation(phases: readonly TodoPhase[]): { phase: number; task: number } | null {
73
+ for (let phase = 0; phase < phases.length; phase++) {
74
+ const tasks = phases[phase].tasks;
75
+ for (let task = 0; task < tasks.length; task++) {
76
+ if (tasks[task].status === 'in_progress') return { phase, task };
77
+ }
78
+ }
79
+ for (let phase = 0; phase < phases.length; phase++) {
80
+ const tasks = phases[phase].tasks;
81
+ for (let task = 0; task < tasks.length; task++) {
82
+ if (tasks[task].status === 'pending') return { phase, task };
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+
88
+ /** Tasks of one phase that are closed, for the phase header's `n/m` badge. */
89
+ export function closedTaskCount(phase: TodoPhase): number {
90
+ return phase.tasks.filter((task) => task.status === 'completed' || task.status === 'abandoned').length;
91
+ }
92
+
93
+ /** Blocker note of a task, trimmed, or undefined when there is nothing to show. */
94
+ export function taskNote(task: TodoItem): string | undefined {
95
+ const note = task.blocker?.trim() || task.details?.trim();
96
+ return note ? note : undefined;
97
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { latestTodoSnapshot, todoProgress } from '@/shared/lib/chat/todo/snapshot';
9
+ import type { TodoPhase, TodoStatus } from '@/shared/types/todo';
10
+
11
+ /**
12
+ * The reader has to match oh-my-pi's own branch rehydration, and every rule it
13
+ * implements was verified against a real session written by omp 18.3.0:
14
+ *
15
+ * - a `todo` toolResult carries `message.details.phases`;
16
+ * - `op:"view"` results carry phases but commit nothing (omp skips them);
17
+ * - a `user_todo_edit` custom entry carries `data.phases` and is durable;
18
+ * - the list is read off the ACTIVE BRANCH (leaf → root via `parentId`), so a
19
+ * rewound fork must not resurface.
20
+ *
21
+ * The failure mode of getting any of these wrong is a panel that quietly shows
22
+ * a stale plan, which no other surface would contradict.
23
+ */
24
+
25
+ const phases = (...tasks: Array<[string, TodoStatus]>): TodoPhase[] => [
26
+ { name: 'Phase', tasks: tasks.map(([content, status]) => ({ content, status })) },
27
+ ];
28
+
29
+ function entry(id: string, parentId: string | null, body: Record<string, unknown>) {
30
+ return { id, parentId, timestamp: '2026-09-25T00:00:00.000Z', ...body };
31
+ }
32
+
33
+ function todoResult(id: string, parentId: string | null, details: Record<string, unknown>) {
34
+ return entry(id, parentId, {
35
+ type: 'message',
36
+ message: { role: 'toolResult', toolName: 'todo', content: [{ type: 'text', text: '' }], details },
37
+ });
38
+ }
39
+
40
+ describe('latestTodoSnapshot', () => {
41
+ test('reads the deepest committed snapshot on the active branch', () => {
42
+ const entries = [
43
+ todoResult('a1', null, { op: 'init', phases: phases(['one', 'pending'], ['two', 'pending']) }),
44
+ todoResult('a2', 'a1', { op: 'done', phases: phases(['one', 'completed'], ['two', 'in_progress']) }),
45
+ ];
46
+
47
+ const snapshot = latestTodoSnapshot(entries);
48
+
49
+ expect(snapshot?.sourceEntryId).toBe('a2');
50
+ expect(snapshot?.source).toBe('toolResult');
51
+ expect(snapshot?.op).toBe('done');
52
+ expect(snapshot?.phases[0].tasks[0].status).toBe('completed');
53
+ });
54
+
55
+ test('skips a `view` result — it reports the list without committing it', () => {
56
+ const entries = [
57
+ todoResult('a1', null, { op: 'init', phases: phases(['one', 'completed']) }),
58
+ // A view result can be arbitrarily stale (it is echoed, not committed).
59
+ todoResult('a2', 'a1', { op: 'view', phases: phases(['one', 'pending']) }),
60
+ ];
61
+
62
+ expect(latestTodoSnapshot(entries)?.sourceEntryId).toBe('a1');
63
+ });
64
+
65
+ test('skips an errored result', () => {
66
+ const entries = [
67
+ todoResult('a1', null, { op: 'init', phases: phases(['one', 'pending']) }),
68
+ {
69
+ ...todoResult('a2', 'a1', { op: 'done', phases: phases(['one', 'completed']) }),
70
+ message: {
71
+ role: 'toolResult',
72
+ toolName: 'todo',
73
+ isError: true,
74
+ details: { op: 'done', phases: phases(['one', 'completed']) },
75
+ },
76
+ },
77
+ ];
78
+
79
+ expect(latestTodoSnapshot(entries)?.sourceEntryId).toBe('a1');
80
+ });
81
+
82
+ test('a `user_todo_edit` custom entry outranks an older tool result', () => {
83
+ const entries = [
84
+ todoResult('a1', null, { op: 'init', phases: phases(['one', 'pending']) }),
85
+ entry('a2', 'a1', {
86
+ type: 'custom',
87
+ customType: 'user_todo_edit',
88
+ data: { phases: phases(['one', 'abandoned']) },
89
+ }),
90
+ ];
91
+
92
+ const snapshot = latestTodoSnapshot(entries);
93
+
94
+ expect(snapshot?.source).toBe('custom');
95
+ expect(snapshot?.phases[0].tasks[0].status).toBe('abandoned');
96
+ });
97
+
98
+ test('a snapshot on an abandoned branch is ignored (rewind)', () => {
99
+ // a2 was rewound past: the leaf is b2, whose parent chain never reaches a2.
100
+ const entries = [
101
+ todoResult('a1', null, { op: 'init', phases: phases(['old', 'in_progress']) }),
102
+ todoResult('a2', 'a1', { op: 'done', phases: phases(['old', 'completed']) }),
103
+ todoResult('b1', 'a1', { op: 'append', phases: phases(['new', 'pending']) }),
104
+ todoResult('b2', 'b1', { op: 'start', phases: phases(['new', 'in_progress']) }),
105
+ ];
106
+
107
+ const snapshot = latestTodoSnapshot(entries);
108
+
109
+ expect(snapshot?.sourceEntryId).toBe('b2');
110
+ expect(snapshot?.phases[0].tasks[0].content).toBe('new');
111
+ });
112
+
113
+ test('returns null when the transcript holds no todo state', () => {
114
+ const entries = [
115
+ entry('a1', null, { type: 'message', message: { role: 'user', content: 'hello' } }),
116
+ todoResult('a2', 'a1', { op: 'view', phases: phases(['one', 'pending']) }),
117
+ ];
118
+
119
+ expect(latestTodoSnapshot(entries)).toBeNull();
120
+ });
121
+
122
+ test('rejects a malformed snapshot rather than rendering a half list', () => {
123
+ const entries = [
124
+ todoResult('a1', null, { op: 'init', phases: [{ name: 'Phase', tasks: [{ content: 'x', status: 'nonsense' }] }] }),
125
+ ];
126
+
127
+ expect(latestTodoSnapshot(entries)).toBeNull();
128
+ });
129
+
130
+ test('carries storage and timestamp when the entry records them', () => {
131
+ const entries = [todoResult('a1', null, { op: 'init', phases: phases(['one', 'pending']), storage: 'memory' })];
132
+
133
+ const snapshot = latestTodoSnapshot(entries);
134
+
135
+ expect(snapshot?.storage).toBe('memory');
136
+ expect(snapshot?.updatedAt).toBe('2026-09-25T00:00:00.000Z');
137
+ });
138
+ });
139
+
140
+ describe('todoProgress', () => {
141
+ test('counts closed as completed plus abandoned', () => {
142
+ const progress = todoProgress(
143
+ phases(['a', 'completed'], ['b', 'abandoned'], ['c', 'in_progress'], ['d', 'blocked'], ['e', 'pending']),
144
+ );
145
+
146
+ expect(progress).toEqual({
147
+ total: 5,
148
+ completed: 1,
149
+ closed: 2,
150
+ inProgress: 1,
151
+ pending: 1,
152
+ blocked: 1,
153
+ abandoned: 1,
154
+ });
155
+ });
156
+ });
@@ -0,0 +1,215 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Parse oh-my-pi's persisted `todo` state out of a session transcript.
8
+ *
9
+ * omp keeps the authoritative list in its own session file, not in an
10
+ * in-memory HUD: the `todo` tool commits a snapshot to the entry it appends,
11
+ * and omp reads the deepest snapshot back off the ACTIVE BRANCH when it
12
+ * rehydrates (`getLatestTodoPhasesFromEntries` → `syncTodoPhasesFromBranch`).
13
+ * This module performs the same read, so the chamber's panel shows what the
14
+ * agent itself would resume with.
15
+ *
16
+ * Two rules are load-bearing and each was verified against omp 18.3.0:
17
+ *
18
+ * - **The active branch, not file order.** A session file is a tree: every
19
+ * entry carries `parentId`, and a rewind/fork leaves entries on the file
20
+ * that the current leaf no longer descends from. omp reads `getBranch()`
21
+ * (leaf → root), so file order would resurrect a todo list the agent had
22
+ * already rewound past.
23
+ * - **A `view` snapshot is not a snapshot.** `todo op=view` returns the
24
+ * current list without committing it; a `view` result's phases may be
25
+ * stale, so omp skips those results entirely and so does this reader.
26
+ * Errored results are skipped for the same reason.
27
+ */
28
+
29
+ import { isRecord } from '@/shared/lib/util/guards';
30
+ import type {
31
+ TodoItem,
32
+ TodoOperation,
33
+ TodoPhase,
34
+ TodoProgress,
35
+ TodoSnapshot,
36
+ } from '@/shared/types/todo';
37
+
38
+ const TODO_STATUSES: Record<string, true> = {
39
+ pending: true,
40
+ in_progress: true,
41
+ completed: true,
42
+ abandoned: true,
43
+ blocked: true,
44
+ };
45
+
46
+ /** Custom-entry type omp writes for a durable, non-tool todo mutation. */
47
+ const USER_TODO_EDIT_CUSTOM_TYPE = 'user_todo_edit';
48
+
49
+ /** Whether an unknown value is one omp-persisted phase. */
50
+ export function isTodoPhase(value: unknown): value is TodoPhase {
51
+ if (!isRecord(value) || typeof value.name !== 'string' || !Array.isArray(value.tasks)) return false;
52
+ return value.tasks.every((task) => {
53
+ if (!isRecord(task) || typeof task.content !== 'string') return false;
54
+ return typeof task.status === 'string' && TODO_STATUSES[task.status] === true;
55
+ });
56
+ }
57
+
58
+ /** Narrow an unknown array to a validated phase list, or undefined. */
59
+ function toPhases(value: unknown): TodoPhase[] | undefined {
60
+ if (!Array.isArray(value) || !value.every(isTodoPhase)) return undefined;
61
+ return value as TodoPhase[];
62
+ }
63
+
64
+ /** One JSONL entry as this reader needs it. */
65
+ interface TodoEntryLike {
66
+ id?: unknown;
67
+ parentId?: unknown;
68
+ timestamp?: unknown;
69
+ type?: unknown;
70
+ customType?: unknown;
71
+ data?: unknown;
72
+ message?: unknown;
73
+ }
74
+
75
+ /**
76
+ * The phases an entry commits, or undefined when it commits none.
77
+ *
78
+ * Mirrors omp's own `aVn`: a `user_todo_edit` custom entry carries `data.phases`
79
+ * (already-normalized), and a `todo` toolResult carries `details.phases` unless
80
+ * it errored or was a pure `view` read.
81
+ */
82
+ function committedPhases(entry: TodoEntryLike): TodoPhase[] | undefined {
83
+ if (entry.type === 'custom' && entry.customType === USER_TODO_EDIT_CUSTOM_TYPE) {
84
+ const data = isRecord(entry.data) ? entry.data : undefined;
85
+ return toPhases(data?.phases);
86
+ }
87
+ if (entry.type !== 'message') return undefined;
88
+ const message = isRecord(entry.message) ? entry.message : undefined;
89
+ if (!message || message.role !== 'toolResult' || message.toolName !== 'todo') return undefined;
90
+ if (message.isError === true) return undefined;
91
+ const details = isRecord(message.details) ? message.details : undefined;
92
+ if (details?.op === 'view') return undefined;
93
+ return toPhases(details?.phases);
94
+ }
95
+
96
+ /** The chain of entry ids from the session's leaf back to the root. */
97
+ function activeBranchIds(entries: TodoEntryLike[]): Set<string> {
98
+ const parentOf = new Map<string, string | null>();
99
+ for (const entry of entries) {
100
+ if (typeof entry.id !== 'string') continue;
101
+ parentOf.set(entry.id, typeof entry.parentId === 'string' ? entry.parentId : null);
102
+ }
103
+ const branch = new Set<string>();
104
+ // The leaf is the last entry in file order: omp appends as it advances, so
105
+ // the newest entry is where the conversation currently stands.
106
+ let cursor: string | null | undefined = undefined;
107
+ for (let i = entries.length - 1; i >= 0; i--) {
108
+ const id = entries[i]?.id;
109
+ if (typeof id === 'string') {
110
+ cursor = id;
111
+ break;
112
+ }
113
+ }
114
+ while (typeof cursor === 'string' && !branch.has(cursor)) {
115
+ branch.add(cursor);
116
+ cursor = parentOf.get(cursor) ?? null;
117
+ }
118
+ return branch;
119
+ }
120
+
121
+ /**
122
+ * The deepest committed todo snapshot on the active branch, or null when this
123
+ * transcript holds none. The returned `sourceEntryId` is the entry a later
124
+ * snapshot has to beat — omp's own identity for the list.
125
+ */
126
+ export function latestTodoSnapshot(entries: readonly unknown[]): TodoSnapshot | null {
127
+ const typed = entries as TodoEntryLike[];
128
+ const branch = activeBranchIds(typed);
129
+
130
+ for (let i = typed.length - 1; i >= 0; i--) {
131
+ const entry = typed[i];
132
+ const id = entry?.id;
133
+ // An entry with no id cannot be placed on the branch; a legacy file may
134
+ // still carry usable snapshots, so it is read but never claims identity.
135
+ if (typeof id === 'string' && !branch.has(id)) continue;
136
+ const phases = committedPhases(entry);
137
+ if (!phases) continue;
138
+ return {
139
+ phases,
140
+ sourceEntryId: typeof id === 'string' ? id : '',
141
+ source: entry.type === 'custom' ? 'custom' : 'toolResult',
142
+ ...(typeof entry.timestamp === 'string' ? { updatedAt: entry.timestamp } : {}),
143
+ ...readOp(entry),
144
+ };
145
+ }
146
+ return null;
147
+ }
148
+
149
+ /** Op + storage recorded on the snapshot entry, when it carries them. */
150
+ function readOp(entry: TodoEntryLike): { op?: TodoOperation; storage?: 'session' | 'memory' } {
151
+ const details =
152
+ entry.type === 'message'
153
+ ? (isRecord(entry.message) ? (isRecord(entry.message.details) ? entry.message.details : undefined) : undefined)
154
+ : undefined;
155
+ if (!details) return {};
156
+ const op = typeof details.op === 'string' ? (details.op as TodoOperation) : undefined;
157
+ const storage = details.storage === 'session' || details.storage === 'memory' ? details.storage : undefined;
158
+ return { ...(op ? { op } : {}), ...(storage ? { storage } : {}) };
159
+ }
160
+
161
+ /** Counter roll-up over every task in a snapshot. */
162
+ export function todoProgress(phases: readonly TodoPhase[]): TodoProgress {
163
+ const progress: TodoProgress = {
164
+ total: 0,
165
+ completed: 0,
166
+ closed: 0,
167
+ inProgress: 0,
168
+ pending: 0,
169
+ blocked: 0,
170
+ abandoned: 0,
171
+ };
172
+ for (const phase of phases) {
173
+ for (const task of phase.tasks) {
174
+ progress.total += 1;
175
+ switch (task.status) {
176
+ case 'completed':
177
+ progress.completed += 1;
178
+ progress.closed += 1;
179
+ break;
180
+ case 'abandoned':
181
+ progress.abandoned += 1;
182
+ progress.closed += 1;
183
+ break;
184
+ case 'in_progress':
185
+ progress.inProgress += 1;
186
+ break;
187
+ case 'blocked':
188
+ progress.blocked += 1;
189
+ break;
190
+ default:
191
+ progress.pending += 1;
192
+ break;
193
+ }
194
+ }
195
+ }
196
+ return progress;
197
+ }
198
+
199
+ /**
200
+ * The task the agent is on right now: an `in_progress` one if any, else the
201
+ * first `pending`. omp picks the same way for its own "next actionable" row.
202
+ */
203
+ export function nextActionableTask(phases: readonly TodoPhase[]): TodoItem | undefined {
204
+ for (const phase of phases) {
205
+ for (const task of phase.tasks) {
206
+ if (task.status === 'in_progress') return task;
207
+ }
208
+ }
209
+ for (const phase of phases) {
210
+ for (const task of phase.tasks) {
211
+ if (task.status === 'pending') return task;
212
+ }
213
+ }
214
+ return undefined;
215
+ }