@kabmo-labs/kabmo 0.8.0 → 0.9.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.
- package/README.md +7 -0
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +314 -314
- package/dist/client/static/js/{index-qwpvd9fv.js → index-bpptfkz2.js} +922 -892
- package/dist/client/static/js/index-k315p1e8.css +1 -0
- package/package.json +9 -1
- package/src/client/components/common/code-surface/documents.test.ts +120 -0
- package/src/client/components/common/code-surface/documents.ts +201 -0
- package/src/client/components/common/code-surface/extensions.test.ts +110 -0
- package/src/client/components/common/code-surface/extensions.ts +162 -0
- package/src/client/components/common/code-surface/handle.ts +102 -0
- package/src/client/components/common/code-surface/index.tsx +253 -314
- package/src/client/components/common/code-surface/keymap.test.ts +133 -0
- package/src/client/components/common/code-surface/keymap.ts +267 -0
- package/src/client/components/common/code-surface/marks.ts +74 -0
- package/src/client/components/common/code-surface/shiki.ts +234 -0
- package/src/client/components/common/code-surface/sync.test.ts +185 -0
- package/src/client/components/common/code-surface/sync.ts +58 -0
- package/src/client/components/common/code-surface/theme.test.ts +202 -0
- package/src/client/components/common/code-surface/theme.ts +111 -0
- package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
- package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
- package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
- package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
- package/src/client/components/common/shell-update-button/index.tsx +125 -0
- package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
- package/src/client/components/common/toolbar-row/index.tsx +189 -0
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
- package/src/client/components/layout/desktop-layout/index.tsx +47 -67
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
- package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
- package/src/client/components/mobile/LayoutWrapper.tsx +1 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
- package/src/client/components/settings/Modal.tsx +8 -0
- package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
- package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +8 -2
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +16 -8
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +8 -11
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
- package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
- package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
- package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
- package/src/client/components/workspace/diff-panel/index.tsx +76 -145
- package/src/client/components/workspace/diff-panel/merge.ts +315 -0
- package/src/client/components/workspace/editor/Body.tsx +4 -7
- package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
- package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
- package/src/client/components/workspace/editor/FilePane.tsx +4 -7
- package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
- package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
- package/src/client/components/workspace/editor/index.tsx +7 -9
- package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
- package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
- package/src/client/components/workspace/search-panel/Body.tsx +1 -1
- package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
- package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
- package/src/client/hooks/chat/omp/reconnect.ts +187 -0
- package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
- package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
- package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
- package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
- package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
- package/src/client/hooks/chat/omp/session-list.tsx +69 -9
- package/src/client/hooks/chat/timeline/generating.ts +19 -2
- package/src/client/hooks/chat/timeline/index.ts +19 -24
- package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
- package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
- package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
- package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
- package/src/client/hooks/chat/timeline/session-load.ts +69 -37
- package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
- package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
- package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
- package/src/client/hooks/code/deferred-highlight.ts +129 -0
- package/src/client/hooks/editor/find-state.ts +1 -1
- package/src/client/hooks/editor/theme.test.ts +206 -0
- package/src/client/hooks/editor/theme.ts +82 -0
- package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
- package/src/client/hooks/editor/use-editor-drop.ts +67 -14
- package/src/client/hooks/editor/use-editor-find.ts +7 -7
- package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
- package/src/client/hooks/editor/use-outline-view.ts +94 -7
- package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
- package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
- package/src/client/hooks/editor/use-search-reveal.ts +1 -1
- package/src/client/hooks/ui/settings-modal.ts +56 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +2 -0
- package/src/client/hooks/ui/updates/shell-download.ts +55 -14
- package/src/client/hooks/ui/updates/shell-offer.test.ts +26 -0
- package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
- package/src/client/hooks/ui/updates/shell-update.ts +119 -0
- package/src/client/hooks/workspace/diff-file.ts +29 -10
- package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
- package/src/client/hooks/workspace/editor-view.ts +61 -0
- package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
- package/src/client/hooks/workspace/worktree/index.ts +20 -5
- package/src/client/lib/desktop/bridge.ts +45 -0
- package/src/client/lib/search-reveal.ts +1 -1
- package/src/client/main.tsx +5 -1
- package/src/client/tailwind.css +37 -8
- package/src/server/lib/auth/guard.test.ts +28 -0
- package/src/server/lib/auth/guard.ts +10 -0
- package/src/server/lib/fs/worktree/create.ts +2 -3
- package/src/server/lib/fs/worktree/list.ts +13 -0
- package/src/server/lib/fs/worktree/remove.ts +3 -3
- package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
- package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
- package/src/server/lib/lifecycle/port-guard.ts +39 -7
- package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
- package/src/server/lib/lifecycle/probe.test.ts +77 -164
- package/src/server/lib/lifecycle/probe.ts +121 -13
- package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
- package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
- package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
- package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
- package/src/server/lib/omp/session/telemetry.test.ts +33 -0
- package/src/server/lib/realtime/signals.server.ts +10 -0
- package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
- package/src/server/lib/realtime/topics.server.ts +16 -0
- package/src/server/lib/updates/shell-assets.ts +94 -3
- package/src/server/lib/updates/shell-cache.ts +105 -0
- package/src/server/lib/updates/shell-pages.test.ts +33 -2
- package/src/server/lib/updates/shell-pages.ts +17 -5
- package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
- package/src/server/lib/updates/shell-release.test.ts +10 -27
- package/src/server/lib/updates/shell-release.ts +107 -111
- package/src/server/routes/fs/git-merge.test.ts +11 -1
- package/src/server/routes/fs/read.test.ts +24 -7
- package/src/server/routes/fs/scope.test.ts +15 -10
- package/src/server/routes/fs/scope.ts +3 -14
- package/src/server/routes/updates/android.ts +6 -1
- package/src/server/routes/updates/desktop.ts +29 -7
- package/src/server/routes/updates/ios.ts +5 -1
- package/src/server/routes/worktrees/list.ts +11 -1
- package/src/server/routes/worktrees/routes.test.ts +17 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
- package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
- package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
- package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
- package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
- package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
- package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
- package/src/shared/lib/code/editor/find-scan.ts +177 -0
- package/src/shared/lib/code/editor/keymap.ts +3 -3
- package/src/shared/lib/code/editor/lines.test.ts +7 -55
- package/src/shared/lib/code/editor/lines.ts +8 -48
- package/src/shared/lib/code/editor/marks.test.ts +4 -5
- package/src/shared/lib/code/editor/marks.ts +13 -25
- package/src/shared/lib/code/editor/theme-loader.ts +97 -0
- package/src/shared/lib/code/editor/themes.generated.ts +156 -0
- package/src/shared/lib/code/editor/themes.test.ts +165 -0
- package/src/shared/lib/code/editor/themes.ts +165 -0
- package/src/shared/lib/code/highlight-memo.test.ts +103 -0
- package/src/shared/lib/code/highlight-memo.ts +149 -0
- package/src/shared/lib/code/highlighter-warmup.ts +112 -0
- package/src/shared/lib/code/highlighter.ts +3 -1
- package/src/shared/lib/code/idle.ts +37 -0
- package/src/shared/lib/code/outline/rows.test.ts +96 -0
- package/src/shared/lib/code/outline/rows.ts +94 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
- package/src/shared/lib/code/syntax-highlight.ts +2 -3
- package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
- package/src/shared/lib/fs/file-kind.ts +47 -13
- package/src/shared/lib/fs/file-url.test.ts +52 -0
- package/src/shared/lib/fs/file-url.ts +37 -0
- package/src/shared/lib/fs/patch-sides.test.ts +114 -0
- package/src/shared/lib/fs/patch-sides.ts +139 -0
- package/src/shared/lib/realtime/protocol.ts +16 -0
- package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
- package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
- package/src/shared/lib/updates/shell-arch.ts +54 -0
- package/src/shared/lib/updates/shell-ua.ts +4 -4
- package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
- package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
- package/src/shared/lib/workspace/panel-widths.ts +25 -5
- package/src/shared/lib/workspace/session-state/cache.ts +159 -0
- package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
- package/src/shared/lib/workspace/session-state/merge.ts +100 -0
- package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
- package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
- package/src/shared/lib/workspace/session-state/store.ts +92 -130
- package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
- package/src/shared/types/realtime.ts +29 -0
- package/src/shared/types/settings/state.ts +10 -0
- package/src/test-support/loopback.ts +183 -0
- package/dist/client/static/js/index-gtbpewr7.css +0 -1
- package/src/client/components/common/code-editor/handler.test.ts +0 -274
- package/src/client/components/common/code-editor/handler.ts +0 -175
- package/src/client/components/common/code-editor/index.tsx +0 -314
- package/src/client/components/common/code-surface/measure.ts +0 -148
- package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +0 -227
- package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +0 -260
- package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
- package/src/client/hooks/editor/scroll-container.test.ts +0 -102
- package/src/client/hooks/editor/scroll-container.ts +0 -26
- package/src/client/hooks/editor/use-code-window.ts +0 -100
- package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
- package/src/client/hooks/editor/use-editor-history.ts +0 -134
- package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
- package/src/client/hooks/editor/use-occurrences.ts +0 -98
- package/src/shared/lib/code/editor/commands.test.ts +0 -155
- package/src/shared/lib/code/editor/commands.ts +0 -260
- package/src/shared/lib/code/editor/edits.test.ts +0 -100
- package/src/shared/lib/code/editor/edits.ts +0 -122
- package/src/shared/lib/code/editor/history.test.ts +0 -118
- package/src/shared/lib/code/editor/history.ts +0 -101
- package/src/shared/lib/code/editor/layers.test.ts +0 -77
- package/src/shared/lib/code/editor/layers.ts +0 -67
- package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
- package/src/shared/lib/code/editor/line-edits.ts +0 -159
- package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
- package/src/shared/lib/code/editor/occurrences.ts +0 -111
- package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
- package/src/shared/lib/code/editor/reset-css.ts +0 -32
- package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
- package/src/shared/lib/code/editor/text-commands.ts +0 -154
- package/src/shared/lib/code/lazy-window.test.ts +0 -161
- package/src/shared/lib/code/lazy-window.ts +0 -156
- package/src/shared/lib/code/windowed-highlight.ts +0 -114
- package/src/shared/lib/fs/diff-parser.test.ts +0 -116
- package/src/shared/lib/fs/diff-parser.ts +0 -213
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `sidebar:activity` resolver, against a real sessions directory.
|
|
8
|
+
*
|
|
9
|
+
* The rules under test are the ones that keep this payload small enough to be
|
|
10
|
+
* republished per tool call:
|
|
11
|
+
*
|
|
12
|
+
* - it carries ONLY sessions with a run in flight — every other row keeps the
|
|
13
|
+
* stamp the structure gave it, which is why the structure does not have to
|
|
14
|
+
* be re-scanned;
|
|
15
|
+
* - a live run with no transcript yet contributes nothing (a fresh spawn:
|
|
16
|
+
* omp writes the file only when the first assistant message settles);
|
|
17
|
+
* - the stamp it reports is the newest ENTRY's timestamp, the same quantity
|
|
18
|
+
* the structure ships as `updated_at`, so the client can compare them;
|
|
19
|
+
* - the subagent flag is reported alongside, because a subagent's transcript
|
|
20
|
+
* appears mid-run and lights the parent row's roster chevron.
|
|
21
|
+
*
|
|
22
|
+
* Driven through `localRuns()` rather than a stubbed registry: that set is the
|
|
23
|
+
* real "a run is in flight" source (the mock SSE stream uses it too), so the
|
|
24
|
+
* test exercises the same read `loadSidebarStatus` does.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { afterAll, afterEach, describe, expect, test } from 'bun:test';
|
|
28
|
+
import fs from 'fs';
|
|
29
|
+
import * as path from 'path';
|
|
30
|
+
|
|
31
|
+
import { loadSidebarActivity } from '@/server/lib/omp/session/sidebar-activity.server';
|
|
32
|
+
import { localRuns } from '@/server/lib/omp/session/local-runs.server';
|
|
33
|
+
import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
|
|
34
|
+
import { SESSION_TITLE_SLOT_BYTES } from '@/server/lib/omp/session/files';
|
|
35
|
+
|
|
36
|
+
const tempDirs: string[] = [];
|
|
37
|
+
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Point the agent dir at a scratch tree, because `findSessionFileById` resolves
|
|
41
|
+
* through the real sessions root. `PI_CODING_AGENT_DIR` is read per call, so no
|
|
42
|
+
* module reload is needed — but the file caches are process-wide and keyed by
|
|
43
|
+
* path, so they are cleared before and after.
|
|
44
|
+
*/
|
|
45
|
+
function useAgentDir(): string {
|
|
46
|
+
const dir = fs.mkdtempSync(path.join(fs.realpathSync('/tmp'), 'kabmo-activity-'));
|
|
47
|
+
tempDirs.push(dir);
|
|
48
|
+
process.env.PI_CODING_AGENT_DIR = dir;
|
|
49
|
+
clearSessionFileCaches();
|
|
50
|
+
return dir;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A title slot line, exactly as omp writes it, so the scan reads a real file. */
|
|
54
|
+
function titleSlot(title: string): string {
|
|
55
|
+
const raw = JSON.stringify({ type: 'title', title });
|
|
56
|
+
return raw + ' '.repeat(Math.max(0, SESSION_TITLE_SLOT_BYTES - 1 - raw.length));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface SessionFixture {
|
|
60
|
+
id: string;
|
|
61
|
+
entries?: Array<{ type: string; timestamp?: string }>;
|
|
62
|
+
subagents?: string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Write one session file under `<agentDir>/sessions/<project>/`.
|
|
67
|
+
*
|
|
68
|
+
* The entries are minimal but carry the field the scan dates the file by (the
|
|
69
|
+
* newest entry's own `timestamp`), which is the whole quantity under test.
|
|
70
|
+
*/
|
|
71
|
+
function writeSession(agentDir: string, fixture: SessionFixture): string {
|
|
72
|
+
const projectDir = path.join(agentDir, 'sessions', '-tmp-project');
|
|
73
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
74
|
+
const file = path.join(projectDir, `2026-10-24T00-00-00-000Z_${fixture.id}.jsonl`);
|
|
75
|
+
const lines = [
|
|
76
|
+
titleSlot(`Session ${fixture.id}`),
|
|
77
|
+
JSON.stringify({ type: 'session', id: fixture.id, cwd: '/tmp/project', timestamp: '2026-10-24T00:00:00.000Z' }),
|
|
78
|
+
...(fixture.entries ?? []).map((entry) => JSON.stringify(entry)),
|
|
79
|
+
];
|
|
80
|
+
fs.writeFileSync(file, `${lines.join('\n')}\n`);
|
|
81
|
+
|
|
82
|
+
if (fixture.subagents?.length) {
|
|
83
|
+
// A sibling directory named after the session file, holding one .jsonl per
|
|
84
|
+
// subagent — the shape `sessionHasSubagents` reads.
|
|
85
|
+
const sibling = file.replace(/\.jsonl$/, '');
|
|
86
|
+
fs.mkdirSync(sibling, { recursive: true });
|
|
87
|
+
for (const id of fixture.subagents) {
|
|
88
|
+
fs.writeFileSync(path.join(sibling, `${id}.jsonl`), `${JSON.stringify({ type: 'message' })}\n`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
clearSessionFileCaches();
|
|
92
|
+
return file;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
localRuns().clear();
|
|
97
|
+
clearSessionFileCaches();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterAll(() => {
|
|
101
|
+
if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
|
102
|
+
else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
|
|
103
|
+
for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('loadSidebarActivity', () => {
|
|
107
|
+
test('carries nothing when no run is in flight', async () => {
|
|
108
|
+
const agentDir = useAgentDir();
|
|
109
|
+
writeSession(agentDir, { id: 'idle', entries: [{ type: 'message', timestamp: '2026-10-24T01:00:00.000Z' }] });
|
|
110
|
+
expect(await loadSidebarActivity()).toEqual({});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('reports the newest entry timestamp of a session with a live run', async () => {
|
|
114
|
+
const agentDir = useAgentDir();
|
|
115
|
+
writeSession(agentDir, {
|
|
116
|
+
id: 'live',
|
|
117
|
+
entries: [
|
|
118
|
+
{ type: 'message', timestamp: '2026-10-24T01:00:00.000Z' },
|
|
119
|
+
{ type: 'message', timestamp: '2026-10-24T01:05:00.000Z' },
|
|
120
|
+
],
|
|
121
|
+
});
|
|
122
|
+
localRuns().add('live');
|
|
123
|
+
|
|
124
|
+
const activity = await loadSidebarActivity();
|
|
125
|
+
expect(Object.keys(activity)).toEqual(['live']);
|
|
126
|
+
expect(activity.live?.updatedAt).toBe('2026-10-24T01:05:00.000Z');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('never reports a session without a run, however recent its transcript', async () => {
|
|
130
|
+
const agentDir = useAgentDir();
|
|
131
|
+
writeSession(agentDir, { id: 'other', entries: [{ type: 'message', timestamp: '2026-10-24T01:00:00.000Z' }] });
|
|
132
|
+
writeSession(agentDir, { id: 'live', entries: [{ type: 'message', timestamp: '2026-10-24T01:00:00.000Z' }] });
|
|
133
|
+
localRuns().add('live');
|
|
134
|
+
|
|
135
|
+
expect(Object.keys(await loadSidebarActivity())).toEqual(['live']);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('omits a live run whose transcript does not exist yet', async () => {
|
|
139
|
+
// A fresh spawn: omp writes the session file only when the first assistant
|
|
140
|
+
// message settles, so there is no stamp to report. Its row is the client's
|
|
141
|
+
// pending row until the scan finds the file.
|
|
142
|
+
useAgentDir();
|
|
143
|
+
localRuns().add('not-written-yet');
|
|
144
|
+
expect(await loadSidebarActivity()).toEqual({});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('reports the subagent flag, which moves mid-run', async () => {
|
|
148
|
+
const agentDir = useAgentDir();
|
|
149
|
+
writeSession(agentDir, {
|
|
150
|
+
id: 'with-sub',
|
|
151
|
+
entries: [{ type: 'message', timestamp: '2026-10-24T01:00:00.000Z' }],
|
|
152
|
+
subagents: ['Sleeper'],
|
|
153
|
+
});
|
|
154
|
+
writeSession(agentDir, { id: 'plain', entries: [{ type: 'message', timestamp: '2026-10-24T01:00:00.000Z' }] });
|
|
155
|
+
localRuns().add('with-sub');
|
|
156
|
+
localRuns().add('plain');
|
|
157
|
+
|
|
158
|
+
const activity = await loadSidebarActivity();
|
|
159
|
+
expect(activity['with-sub']?.hasSubagents).toBe(true);
|
|
160
|
+
expect(activity.plain?.hasSubagents).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `sidebar:activity` resolver: the two row fields a RUNNING session can
|
|
8
|
+
* still move between turn boundaries.
|
|
9
|
+
*
|
|
10
|
+
* Split out of `sidebar-data.server.ts` (which owns the structure and the
|
|
11
|
+
* status) because it answers a different question at a different cadence: the
|
|
12
|
+
* structure is re-scanned at turn boundaries, while this is resolved on every
|
|
13
|
+
* tool call — and its whole reason to exist is that the structure must NOT be.
|
|
14
|
+
*
|
|
15
|
+
* A run appends to its transcript on every tool call, so its row's age and its
|
|
16
|
+
* `LATEST_SESSION` position move mid-run. Republishing the structure for that
|
|
17
|
+
* cost 216 KB over 731 sessions per tool call (measured on the author's
|
|
18
|
+
* install, against 87 bytes for one live entry here), which is what made a
|
|
19
|
+
* phone's stream feel slow. Only a RUNNING session can move between boundaries,
|
|
20
|
+
* so only those are carried.
|
|
21
|
+
*
|
|
22
|
+
* A live run with no session FILE yet (a fresh spawn: omp writes it only when
|
|
23
|
+
* the first assistant message settles) contributes nothing — there is no stamp
|
|
24
|
+
* to report, and its sidebar row is the client's pending row until the scan
|
|
25
|
+
* finds the file.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { isMockMode } from '@/server/mock.server';
|
|
29
|
+
import { getLiveRunSessionIds } from '@/server/lib/omp/rpc/session-registry';
|
|
30
|
+
import { findSessionFileById } from '@/server/lib/omp/session/locator';
|
|
31
|
+
import { scanSessionInfoCached } from '@/server/lib/omp/session/files';
|
|
32
|
+
import { sessionHasSubagents } from '@/server/lib/omp/session/subagent-presence';
|
|
33
|
+
import type { SidebarActivityPayload } from '@/shared/types/realtime';
|
|
34
|
+
|
|
35
|
+
export async function loadSidebarActivity(): Promise<SidebarActivityPayload> {
|
|
36
|
+
const activity: SidebarActivityPayload = {};
|
|
37
|
+
// MOCK answers nothing, and that is not a shortcut: the simulated stream is
|
|
38
|
+
// an SSE route, not an omp child, so no `tool_execution_end` frame ever fires
|
|
39
|
+
// there and there is no between-boundary movement to report. Its rows also
|
|
40
|
+
// read `created_at` (the mock `sessions` table has no `updated_at`), so a
|
|
41
|
+
// `chat_sessions` stamp would be a different quantity under the same name —
|
|
42
|
+
// the one thing this payload must never be.
|
|
43
|
+
if (isMockMode()) return activity;
|
|
44
|
+
|
|
45
|
+
const live = getLiveRunSessionIds();
|
|
46
|
+
if (live.size === 0) return activity;
|
|
47
|
+
const entries = await Promise.all(
|
|
48
|
+
[...live].map(async (sessionId) => {
|
|
49
|
+
const filePath = await findSessionFileById(sessionId);
|
|
50
|
+
if (!filePath) return null;
|
|
51
|
+
const info = await scanSessionInfoCached(filePath);
|
|
52
|
+
if (!info) return null;
|
|
53
|
+
// `modified` is the newest entry's own timestamp, which is what the
|
|
54
|
+
// structure ships as `updated_at` — the same quantity, so the client can
|
|
55
|
+
// compare the two as ISO strings.
|
|
56
|
+
const updatedAt = info.modified.toISOString();
|
|
57
|
+
// The chevron's flag moves mid-run too: a subagent writes its own
|
|
58
|
+
// transcript beside the parent, and the row it lights is the parent's.
|
|
59
|
+
// Cost is a stat plus (on a change) one readdir, and the presence module
|
|
60
|
+
// memoizes on `(last entry, sibling mtime)` — measured 4-8 ms on a cache
|
|
61
|
+
// miss, 0 on a hit.
|
|
62
|
+
const hasSubagents = await sessionHasSubagents(filePath, updatedAt);
|
|
63
|
+
return [sessionId, { updatedAt, hasSubagents }] as const;
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
if (entry) activity[entry[0]] = entry[1];
|
|
68
|
+
}
|
|
69
|
+
return activity;
|
|
70
|
+
}
|
|
@@ -10,6 +10,15 @@ import { scanSessionEntries } from '@/server/lib/omp/session/telemetry/scan';
|
|
|
10
10
|
* Compute real `SessionContextTelemetry` for a session file (all-zero on
|
|
11
11
|
* unreadable/empty). `rawLimit` caps materialized raw items (0 = none): the
|
|
12
12
|
* panel reads full pages from `computeRawMessagesPage` in ./telemetry-raw.ts.
|
|
13
|
+
*
|
|
14
|
+
* The default is 0 — no raw items — and that is load-bearing rather than
|
|
15
|
+
* tidy: this value is published as the `session:<id>:telemetry` topic, which
|
|
16
|
+
* the navbar ring subscribes to for EVERY open session and the server
|
|
17
|
+
* republishes on every tool call. Measured on a 111-message transcript, the
|
|
18
|
+
* full `rawMessages` list (each item carrying its whole entry as
|
|
19
|
+
* `rawPayload`) was **690 KB of a 691 KB snapshot** — 99.9% of every push,
|
|
20
|
+
* per tool call, per tab. Nothing reads it: the panel pages raw items from
|
|
21
|
+
* `/api/telemetry/raw-messages`, and `/api/telemetry/context` strips them.
|
|
13
22
|
*/
|
|
14
23
|
export async function computeRealSessionTelemetry(
|
|
15
24
|
filePath: string,
|
|
@@ -106,7 +115,10 @@ export async function computeRealSessionTelemetry(
|
|
|
106
115
|
|
|
107
116
|
if (role === 'user' || role === 'assistant') {
|
|
108
117
|
messagesCount++;
|
|
109
|
-
|
|
118
|
+
// `rawLimit` is a CAP, so 0 means "none" — the guard must not read 0 as
|
|
119
|
+
// "unlimited", which is what it did and what put the whole transcript on
|
|
120
|
+
// the telemetry topic (see the doc above).
|
|
121
|
+
if (rawLimit > 0 && rawMessages.length < rawLimit) {
|
|
110
122
|
const isAssistant = role === 'assistant';
|
|
111
123
|
const snippet = text.slice(0, 70);
|
|
112
124
|
rawMessages.push({
|
|
@@ -152,3 +152,36 @@ describe('computeRealSessionTelemetry context anchor', () => {
|
|
|
152
152
|
expect(telemetry.contextUsed).toBe(2200);
|
|
153
153
|
});
|
|
154
154
|
});
|
|
155
|
+
|
|
156
|
+
describe('computeRealSessionTelemetry raw items', () => {
|
|
157
|
+
// The default is the TELEMETRY TOPIC's payload, which the navbar ring keeps
|
|
158
|
+
// subscribed for every open session and the server republishes on every tool
|
|
159
|
+
// call. A raw item carries its whole entry as `rawPayload`, so reading 0 as
|
|
160
|
+
// "unlimited" put the entire transcript on that topic — measured 690 KB of a
|
|
161
|
+
// 691 KB snapshot on a 111-message session.
|
|
162
|
+
test('materializes no raw items by default', async () => {
|
|
163
|
+
const file = await writeSession([
|
|
164
|
+
header,
|
|
165
|
+
userMessage('u1', 'first turn'),
|
|
166
|
+
assistantMessage('a1', { usage: { input: 10, output: 5 } }),
|
|
167
|
+
userMessage('u2', 'second turn'),
|
|
168
|
+
assistantMessage('a2', { usage: { input: 20, output: 5 } }),
|
|
169
|
+
]);
|
|
170
|
+
const telemetry = await computeRealSessionTelemetry(file, 'ses_telemetry');
|
|
171
|
+
expect(telemetry.messagesCount).toBe(4);
|
|
172
|
+
expect(telemetry.rawMessages).toEqual([]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('caps raw items at a positive limit', async () => {
|
|
176
|
+
const file = await writeSession([
|
|
177
|
+
header,
|
|
178
|
+
userMessage('u1', 'first turn'),
|
|
179
|
+
assistantMessage('a1', { usage: { input: 10, output: 5 } }),
|
|
180
|
+
userMessage('u2', 'second turn'),
|
|
181
|
+
assistantMessage('a2', { usage: { input: 20, output: 5 } }),
|
|
182
|
+
]);
|
|
183
|
+
const telemetry = await computeRealSessionTelemetry(file, 'ses_telemetry', undefined, 2);
|
|
184
|
+
expect(telemetry.rawMessages).toHaveLength(2);
|
|
185
|
+
expect(telemetry.rawMessages[0]?.id).toBe('u1');
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -22,6 +22,16 @@ export type RealtimeSignal =
|
|
|
22
22
|
| 'stream-status'
|
|
23
23
|
/** The sidebar's structure changed (a session or folder was added/removed). */
|
|
24
24
|
| 'sidebar-structure'
|
|
25
|
+
/**
|
|
26
|
+
* A RUNNING session's transcript grew, so its last-activity stamp moved.
|
|
27
|
+
*
|
|
28
|
+
* Raised per tool call, which is the whole reason it is not
|
|
29
|
+
* `sidebar-structure`: that payload is every folder and session (216 KB over
|
|
30
|
+
* 731 sessions, measured), while only a running session's stamp can move
|
|
31
|
+
* between turn boundaries. Republishing the structure here is what made a
|
|
32
|
+
* phone's stream feel slow — one full list per tool call.
|
|
33
|
+
*/
|
|
34
|
+
| 'sidebar-activity'
|
|
25
35
|
/** An omp child for `sessionId` became reachable, or was torn down. */
|
|
26
36
|
| 'session-attached'
|
|
27
37
|
/** A scheduled task was created, edited, paused, deleted or fired. */
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
TOPIC_PANELS,
|
|
21
21
|
TOPIC_SCHEDULE,
|
|
22
22
|
TOPIC_SIDEBAR,
|
|
23
|
+
TOPIC_SIDEBAR_ACTIVITY,
|
|
23
24
|
TOPIC_SIDEBAR_STATUS,
|
|
24
25
|
TOPIC_USAGE,
|
|
25
26
|
TOPIC_WAITING,
|
|
@@ -28,10 +29,11 @@ import {
|
|
|
28
29
|
sessionTelemetryTopic,
|
|
29
30
|
sessionTodosTopic,
|
|
30
31
|
} from '@/shared/lib/realtime/protocol';
|
|
31
|
-
import type { SidebarPayload, SidebarStatusPayload } from '@/shared/types/realtime';
|
|
32
|
+
import type { SidebarActivityPayload, SidebarPayload, SidebarStatusPayload } from '@/shared/types/realtime';
|
|
32
33
|
import { isValidPluginId } from '@/shared/lib/plugins/protocol';
|
|
33
34
|
import type { TopicDescriptor } from '@/server/lib/realtime/hub.types';
|
|
34
35
|
import { loadSidebarStatus, loadSidebarStructure } from '@/server/lib/omp/session/sidebar-data.server';
|
|
36
|
+
import { loadSidebarActivity } from '@/server/lib/omp/session/sidebar-activity.server';
|
|
35
37
|
import { sessionDescriptor } from '@/server/lib/realtime/topics/session.server';
|
|
36
38
|
import { btwDescriptor } from '@/server/lib/realtime/topics/btw.server';
|
|
37
39
|
import { SESSION_DATA_TOPICS } from '@/server/lib/realtime/topics/session-data.server';
|
|
@@ -64,6 +66,7 @@ export const SCOPED_FAMILIES: Record<string, (scope: string) => TopicDescriptor>
|
|
|
64
66
|
export const EXACT_TOPICS = new Map<string, TopicDescriptor>([
|
|
65
67
|
[TOPIC_SIDEBAR, { resolve: async (): Promise<SidebarPayload> => loadSidebarStructure() }],
|
|
66
68
|
[TOPIC_SIDEBAR_STATUS, { resolve: async (): Promise<SidebarStatusPayload> => loadSidebarStatus() }],
|
|
69
|
+
[TOPIC_SIDEBAR_ACTIVITY, { resolve: async (): Promise<SidebarActivityPayload> => loadSidebarActivity() }],
|
|
67
70
|
[TOPIC_SCHEDULE, { resolve: () => globalDescriptor(TOPIC_SCHEDULE).resolve(TOPIC_SCHEDULE) }],
|
|
68
71
|
[TOPIC_USAGE, { resolve: () => globalDescriptor(TOPIC_USAGE).resolve(TOPIC_USAGE) }],
|
|
69
72
|
[TOPIC_PANELS, { resolve: () => globalDescriptor(TOPIC_PANELS).resolve(TOPIC_PANELS) }],
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
TOPIC_PANELS,
|
|
19
19
|
TOPIC_SCHEDULE,
|
|
20
20
|
TOPIC_SIDEBAR,
|
|
21
|
+
TOPIC_SIDEBAR_ACTIVITY,
|
|
21
22
|
TOPIC_SIDEBAR_STATUS,
|
|
22
23
|
TOPIC_USAGE,
|
|
23
24
|
TOPIC_WAITING,
|
|
@@ -29,6 +30,7 @@ import { getRealtimeHub, registerTopics, type TopicDescriptor } from '@/server/l
|
|
|
29
30
|
import { onRealtimeSignal } from '@/server/lib/realtime/signals.server';
|
|
30
31
|
import { invalidateOmpSidebarData } from '@/server/lib/omp/session/reader';
|
|
31
32
|
import { loadSidebarStatus, loadSidebarStructure } from '@/server/lib/omp/session/sidebar-data.server';
|
|
33
|
+
import { loadSidebarActivity } from '@/server/lib/omp/session/sidebar-activity.server';
|
|
32
34
|
import { EXACT_TOPICS, descriptorFor, isKnownTopicName } from '@/server/lib/realtime/topics/catalog.server';
|
|
33
35
|
|
|
34
36
|
declare global {
|
|
@@ -78,6 +80,7 @@ export function initRealtimeTopics(): void {
|
|
|
78
80
|
onRealtimeSignal(({ signal, sessionId, root }) => {
|
|
79
81
|
if (signal === 'stream-status') publishSidebarStatus();
|
|
80
82
|
else if (signal === 'sidebar-structure') publishSidebarStructure();
|
|
83
|
+
else if (signal === 'sidebar-activity') publishSidebarActivity();
|
|
81
84
|
else if (signal === 'session-attached' && sessionId) publishSessionState(sessionId);
|
|
82
85
|
else if (signal === 'schedule-changed') publishTopic(TOPIC_SCHEDULE);
|
|
83
86
|
else if (signal === 'panels-changed') publishTopic(TOPIC_PANELS);
|
|
@@ -244,6 +247,19 @@ export function publishSidebarStatus(): void {
|
|
|
244
247
|
schedulePublish(TOPIC_SIDEBAR_STATUS, loadSidebarStatus);
|
|
245
248
|
}
|
|
246
249
|
|
|
250
|
+
/**
|
|
251
|
+
* A RUNNING session's transcript grew: republish its activity stamps.
|
|
252
|
+
*
|
|
253
|
+
* Called per tool call (see `handleToolExecutionEnd`), so it must never be the
|
|
254
|
+
* structure publish: that one invalidates the discovery scan and re-serializes
|
|
255
|
+
* every folder and session. This payload carries one ISO string per live run
|
|
256
|
+
* and re-reads nothing but those files' own summaries (each memoized on
|
|
257
|
+
* `(size, mtimeMs)`, measured 0.1 ms on a cache miss).
|
|
258
|
+
*/
|
|
259
|
+
export function publishSidebarActivity(): void {
|
|
260
|
+
schedulePublish(TOPIC_SIDEBAR_ACTIVITY, loadSidebarActivity);
|
|
261
|
+
}
|
|
262
|
+
|
|
247
263
|
/**
|
|
248
264
|
* The sidebar's STRUCTURE changed: a session was created, deleted, renamed, a
|
|
249
265
|
* folder was added or removed, or a JSONL grew a new session.
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* version against a shell version.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import type { ShellArch } from '@/shared/lib/updates/shell-arch';
|
|
15
16
|
import type { ShellKind } from '@/shared/lib/updates/shell-ua';
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -28,7 +29,13 @@ import type { ShellKind } from '@/shared/lib/updates/shell-ua';
|
|
|
28
29
|
* The desktop entries match on the segment AFTER the version rather than on the
|
|
29
30
|
* whole name, because one release carries several files per platform
|
|
30
31
|
* (`-mac-arm64.zip` and `-mac-arm64.dmg`, four Linux formats, two arches). Any
|
|
31
|
-
* one of them answers the same version,
|
|
32
|
+
* one of them answers the same version, which is all `pattern` is for.
|
|
33
|
+
*
|
|
34
|
+
* WHICH file the console hands back is a second question, and `selfUpdate` is
|
|
35
|
+
* the answer: a desktop shell that knows its architecture installs one exact
|
|
36
|
+
* artifact, and a probe that does not know it still gets the release page. Both
|
|
37
|
+
* live in this table so the route, the pages fallback and the tests read one
|
|
38
|
+
* spelling.
|
|
32
39
|
*
|
|
33
40
|
* **`legacy` is not dead code.** These names are PARSED to answer "is a newer
|
|
34
41
|
* shell published", and every release already on GitHub carries the old mobile
|
|
@@ -45,47 +52,131 @@ export interface AssetScheme {
|
|
|
45
52
|
/** The old mobile spelling, or null for a shell that never had one. */
|
|
46
53
|
legacy: { prefix: string; extension: string } | null;
|
|
47
54
|
/**
|
|
48
|
-
* Whether the payload's `url` should be the artifact itself.
|
|
49
|
-
*
|
|
55
|
+
* Whether the payload's `url` should be the artifact itself. True for the
|
|
56
|
+
* mobile pair, whose artifact is the same file on every device.
|
|
50
57
|
*/
|
|
51
58
|
directDownload: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* The ONE file a desktop shell installs ITSELF from, per architecture.
|
|
61
|
+
*
|
|
62
|
+
* `null` for the mobile pair: an APK is installed by `PackageInstaller` and an
|
|
63
|
+
* IPA cannot be installed at all, so neither is this shell's own business.
|
|
64
|
+
*
|
|
65
|
+
* The desktop entry is deliberately narrower than `pattern`: one release
|
|
66
|
+
* carries several files per platform (`.zip` and `.dmg`, `.tar.gz`/`.deb`/
|
|
67
|
+
* `.rpm`/`.AppImage`), and only ONE of each is something the shell can install
|
|
68
|
+
* without a human opening it. The choice is stated here rather than at the
|
|
69
|
+
* call site so the route, the pages fallback and the tests cannot disagree
|
|
70
|
+
* about which file "the update" means.
|
|
71
|
+
*/
|
|
72
|
+
selfUpdate: SelfUpdateScheme | null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* How one desktop shell's self-update artifact is named.
|
|
77
|
+
*
|
|
78
|
+
* `pattern` captures the version AND the architecture, because the two answers
|
|
79
|
+
* come from the same string: the version is what the console compares against
|
|
80
|
+
* the installed build, and the architecture is what makes this file the right
|
|
81
|
+
* one for the machine asking.
|
|
82
|
+
*/
|
|
83
|
+
export interface SelfUpdateScheme {
|
|
84
|
+
/** `Kabmo-<version>-<platform>-<arch><suffix>` → `[name, version, arch]`. */
|
|
85
|
+
pattern: RegExp;
|
|
86
|
+
/** How each canonical arch is spelled in the ASSET name. */
|
|
87
|
+
assetArch: Record<ShellArch, string>;
|
|
52
88
|
}
|
|
53
89
|
|
|
54
90
|
const VERSION = String.raw`(\d+\.\d+\.\d+)`;
|
|
55
91
|
|
|
92
|
+
/** `arm64`/`x64` as macOS and Windows spell them. */
|
|
93
|
+
const SAME_ARCH: Record<ShellArch, string> = { arm64: 'arm64', x64: 'x64' };
|
|
94
|
+
|
|
95
|
+
/** Linux says `x86_64` where the other two say `x64`. */
|
|
96
|
+
const LINUX_ARCH: Record<ShellArch, string> = { arm64: 'arm64', x64: 'x86_64' };
|
|
97
|
+
|
|
56
98
|
export const SHELL_ASSETS: Record<ShellKind, AssetScheme> = {
|
|
57
99
|
android: {
|
|
58
100
|
pattern: new RegExp(`^Kabmo-${VERSION}-android\\.apk$`),
|
|
59
101
|
current: /^Kabmo-\d/,
|
|
60
102
|
legacy: { prefix: 'kabmo-android-v', extension: '.apk' },
|
|
61
103
|
directDownload: true,
|
|
104
|
+
selfUpdate: null,
|
|
62
105
|
},
|
|
63
106
|
ios: {
|
|
64
107
|
pattern: new RegExp(`^Kabmo-${VERSION}-ios\\.ipa$`),
|
|
65
108
|
current: /^Kabmo-\d/,
|
|
66
109
|
legacy: { prefix: 'kabmo-ios-v', extension: '.ipa' },
|
|
67
110
|
directDownload: true,
|
|
111
|
+
selfUpdate: null,
|
|
68
112
|
},
|
|
69
113
|
macos: {
|
|
70
114
|
pattern: new RegExp(`^Kabmo-${VERSION}-mac-`),
|
|
71
115
|
current: /^Kabmo-\d/,
|
|
72
116
|
legacy: null,
|
|
73
117
|
directDownload: false,
|
|
118
|
+
// The `.zip`, not the `.dmg`: `ditto -x -k` extracts it without mounting a
|
|
119
|
+
// volume, and the shell has to be able to do that with no human present.
|
|
120
|
+
// Both shapes are published; only one is installable by the app itself.
|
|
121
|
+
selfUpdate: {
|
|
122
|
+
pattern: new RegExp(`^Kabmo-${VERSION}-mac-(arm64|x64)\\.zip$`),
|
|
123
|
+
assetArch: SAME_ARCH,
|
|
124
|
+
},
|
|
74
125
|
},
|
|
75
126
|
windows: {
|
|
76
127
|
pattern: new RegExp(`^Kabmo-${VERSION}-win-`),
|
|
77
128
|
current: /^Kabmo-\d/,
|
|
78
129
|
legacy: null,
|
|
79
130
|
directDownload: false,
|
|
131
|
+
// The INSTALLER, not the `.zip`-shaped publish output: Inno already owns the
|
|
132
|
+
// upgrade semantics (a stable `AppId` is what makes an install an upgrade
|
|
133
|
+
// rather than a second copy), and `/CLOSEAPPLICATIONS` is how it replaces a
|
|
134
|
+
// running exe.
|
|
135
|
+
selfUpdate: {
|
|
136
|
+
pattern: new RegExp(`^Kabmo-${VERSION}-win-(arm64|x64)-setup\\.exe$`),
|
|
137
|
+
assetArch: SAME_ARCH,
|
|
138
|
+
},
|
|
80
139
|
},
|
|
81
140
|
linux: {
|
|
82
141
|
pattern: new RegExp(`^Kabmo-${VERSION}-linux-`),
|
|
83
142
|
current: /^Kabmo-\d/,
|
|
84
143
|
legacy: null,
|
|
85
144
|
directDownload: false,
|
|
145
|
+
// The AppImage, and only it: `.deb`/`.rpm` need root and a package manager,
|
|
146
|
+
// so a shell cannot install them — it says so instead. An AppImage is ONE
|
|
147
|
+
// file the shell can replace atomically and re-exec.
|
|
148
|
+
selfUpdate: {
|
|
149
|
+
pattern: new RegExp(`^Kabmo-${VERSION}-linux-(arm64|x86_64)\\.AppImage$`),
|
|
150
|
+
assetArch: LINUX_ARCH,
|
|
151
|
+
},
|
|
86
152
|
},
|
|
87
153
|
};
|
|
88
154
|
|
|
155
|
+
/**
|
|
156
|
+
* The self-update artifact for one architecture, if this name is it.
|
|
157
|
+
*
|
|
158
|
+
* Null for a shell that has no self-update, for an arch it does not publish, and
|
|
159
|
+
* for every other file in the release (a `.dmg`, a `.deb`, the OTHER
|
|
160
|
+
* architecture's build). Callers treat null as "not the file this machine
|
|
161
|
+
* installs", which is why the route falls back to the release page rather than
|
|
162
|
+
* guessing.
|
|
163
|
+
*/
|
|
164
|
+
export function selfUpdateArtifact(
|
|
165
|
+
kind: ShellKind,
|
|
166
|
+
arch: ShellArch,
|
|
167
|
+
name: string,
|
|
168
|
+
): { version: string } | null {
|
|
169
|
+
const scheme = SHELL_ASSETS[kind].selfUpdate;
|
|
170
|
+
if (!scheme) return null;
|
|
171
|
+
|
|
172
|
+
const match = scheme.pattern.exec(name);
|
|
173
|
+
if (!match) return null;
|
|
174
|
+
// The captured arch is compared against the ASSET spelling, so a Linux build
|
|
175
|
+
// reporting `x64` matches `…-linux-x86_64.AppImage` and nothing else.
|
|
176
|
+
if (match[2] !== scheme.assetArch[arch]) return null;
|
|
177
|
+
return { version: match[1] ?? '' };
|
|
178
|
+
}
|
|
179
|
+
|
|
89
180
|
/**
|
|
90
181
|
* `Kabmo-0.5.0-android.apk` → `0.5.0`, the `.ipa` twin, every desktop spelling,
|
|
91
182
|
* plus the legacy `kabmo-android-v0.5.0.apk` the older releases still carry.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The shell-release answer cache, and where the GitHub reads go.
|
|
8
|
+
*
|
|
9
|
+
* Split from `shell-release.ts` for two reasons that are not cosmetic.
|
|
10
|
+
*
|
|
11
|
+
* 1. **The key is per SHELL AND per ARCHITECTURE.** A desktop shell asks for the
|
|
12
|
+
* artifact of its own machine, so a single per-kind slot would answer an
|
|
13
|
+
* arm64 window with the x64 installer the moment both hit one console — and
|
|
14
|
+
* the console is routinely shared by a whole team.
|
|
15
|
+
* 2. **The cache outlives a test file.** It lives on `globalThis` so `bun --hot`
|
|
16
|
+
* cannot orphan it, which is also why `resetShellReleaseCache` exists: without
|
|
17
|
+
* that seam the second case in a suite reads the first case's answer and
|
|
18
|
+
* passes for the wrong reason (measured — the "malformed body" case returned
|
|
19
|
+
* the previous test's release).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ShellArch } from '@/shared/lib/updates/shell-arch';
|
|
23
|
+
import type { ShellKind } from '@/shared/lib/updates/shell-ua';
|
|
24
|
+
|
|
25
|
+
/** How long an answer is reused. */
|
|
26
|
+
export const CACHE_MS = 5 * 60_000;
|
|
27
|
+
|
|
28
|
+
/** One shell's (and, for a desktop, one architecture's) cached answer. */
|
|
29
|
+
export interface CacheSlot<T> {
|
|
30
|
+
entry: T | null;
|
|
31
|
+
at: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type ShellCacheHost = typeof globalThis & {
|
|
35
|
+
__kabmoShellRelease?: Record<string, CacheSlot<unknown>>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The cache key for one question.
|
|
40
|
+
*
|
|
41
|
+
* The arch is part of the KEY rather than a field on the value, so a stale entry
|
|
42
|
+
* can never be read by the wrong machine: a miss is a miss, and there is no
|
|
43
|
+
* comparison left to get wrong.
|
|
44
|
+
*/
|
|
45
|
+
export function shellCacheKey(kind: ShellKind, arch: ShellArch | null): string {
|
|
46
|
+
return arch ? `${kind}:${arch}` : kind;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The cache slot for one key, created on first use. */
|
|
50
|
+
export function cacheSlot<T>(key: string): CacheSlot<T> {
|
|
51
|
+
const host = globalThis as ShellCacheHost;
|
|
52
|
+
host.__kabmoShellRelease ??= {};
|
|
53
|
+
host.__kabmoShellRelease[key] ??= { entry: null, at: 0 };
|
|
54
|
+
return host.__kabmoShellRelease[key] as CacheSlot<T>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Drop the cached answer for one shell (or every shell, with no argument).
|
|
59
|
+
*
|
|
60
|
+
* Production has no caller; it is the test seam described above.
|
|
61
|
+
*/
|
|
62
|
+
export function resetShellReleaseCache(kind?: ShellKind): void {
|
|
63
|
+
const host = globalThis as ShellCacheHost;
|
|
64
|
+
if (!kind) {
|
|
65
|
+
host.__kabmoShellRelease = {};
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const prefix = `${kind}:`;
|
|
69
|
+
const slots = host.__kabmoShellRelease ?? {};
|
|
70
|
+
for (const key of Object.keys(slots)) {
|
|
71
|
+
if (key === kind || key.startsWith(prefix)) delete slots[key];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Where the release read goes.
|
|
77
|
+
*
|
|
78
|
+
* Overridable for the same reason the npm probe honours `NPM_CONFIG_REGISTRY`: a
|
|
79
|
+
* test and a rehearsal need to exercise the real parsing against a stub instead
|
|
80
|
+
* of a live GitHub call, and hardcoding the host makes that impossible without
|
|
81
|
+
* intercepting `fetch` globally.
|
|
82
|
+
*/
|
|
83
|
+
export function githubApiBase(): string {
|
|
84
|
+
const configured = Bun.env.KABMO_GITHUB_API ?? '';
|
|
85
|
+
return configured.trim().replace(/\/+$/, '') || 'https://api.github.com';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A token, when the operator has one, which lifts the API quota from 60 an hour
|
|
90
|
+
* to 5000 — the difference between the precise read working on a shared address
|
|
91
|
+
* and falling through to the pages every time.
|
|
92
|
+
*
|
|
93
|
+
* Read from the environment rather than a setting: it is a deployment fact, it
|
|
94
|
+
* must never reach the browser, and `kabmo serve` already passes its own
|
|
95
|
+
* environment through.
|
|
96
|
+
*/
|
|
97
|
+
export function gitHubHeaders(): Record<string, string> {
|
|
98
|
+
const token = (Bun.env.KABMO_GITHUB_TOKEN ?? Bun.env.GITHUB_TOKEN ?? '').trim();
|
|
99
|
+
const headers: Record<string, string> = {
|
|
100
|
+
// GitHub rejects a request with no User-Agent.
|
|
101
|
+
'user-agent': 'kabmo-console',
|
|
102
|
+
};
|
|
103
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
104
|
+
return headers;
|
|
105
|
+
}
|