oh-my-opencode-dashboard 0.0.3 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-CZM2MUUs.js +40 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/App.tsx +154 -42
- package/src/app-payload.test.ts +158 -0
- package/src/background-task-timeline.test.ts +32 -0
- package/src/ingest/background-tasks.test.ts +295 -2
- package/src/ingest/background-tasks.ts +67 -28
- package/src/ingest/model.ts +79 -0
- package/src/ingest/session.test.ts +119 -0
- package/src/ingest/session.ts +4 -0
- package/src/server/api.test.ts +2 -2
- package/src/server/dashboard.test.ts +139 -0
- package/src/server/dashboard.ts +38 -3
- package/src/timeseries-stacked.test.ts +261 -0
- package/src/timeseries-stacked.ts +145 -0
- package/dist/assets/index-Cs5xePn_.js +0 -40
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import * as fs from "node:fs"
|
|
2
1
|
import * as os from "node:os"
|
|
3
2
|
import * as path from "node:path"
|
|
4
|
-
import
|
|
3
|
+
import * as fs from "node:fs"
|
|
4
|
+
import { describe, expect, it, vi } from "vitest"
|
|
5
5
|
import { deriveBackgroundTasks } from "./background-tasks"
|
|
6
6
|
import { getStorageRoots } from "./session"
|
|
7
7
|
|
|
@@ -602,6 +602,7 @@ describe("deriveBackgroundTasks", () => {
|
|
|
602
602
|
expect(rows[0].toolCalls).toBe(0) // No tool calls in fallback session
|
|
603
603
|
expect(rows[0].lastTool).toBe(null)
|
|
604
604
|
expect(rows[0].status).toBe("unknown") // Should be unknown since session exists but no tool calls
|
|
605
|
+
expect(rows[0].timeline).toBe("") // Unknown status should emit no timeline
|
|
605
606
|
})
|
|
606
607
|
|
|
607
608
|
it("links sync delegate_task rows to Background sessions when forced-to-background but waited", () => {
|
|
@@ -704,4 +705,296 @@ describe("deriveBackgroundTasks", () => {
|
|
|
704
705
|
expect((rows[0] as unknown as Record<string, unknown>).input).toBeUndefined()
|
|
705
706
|
expect((rows[0] as unknown as Record<string, unknown>).state).toBeUndefined()
|
|
706
707
|
})
|
|
708
|
+
|
|
709
|
+
it("derives lastModel from background session assistant metas", () => {
|
|
710
|
+
// #given
|
|
711
|
+
const storageRoot = mkStorageRoot()
|
|
712
|
+
const storage = getStorageRoots(storageRoot)
|
|
713
|
+
const mainSessionId = "ses_main"
|
|
714
|
+
|
|
715
|
+
const msgDir = path.join(storage.message, mainSessionId)
|
|
716
|
+
fs.mkdirSync(msgDir, { recursive: true })
|
|
717
|
+
const messageID = "msg_1"
|
|
718
|
+
fs.writeFileSync(
|
|
719
|
+
path.join(msgDir, `${messageID}.json`),
|
|
720
|
+
JSON.stringify({
|
|
721
|
+
id: messageID,
|
|
722
|
+
sessionID: mainSessionId,
|
|
723
|
+
role: "assistant",
|
|
724
|
+
time: { created: 1000 },
|
|
725
|
+
}),
|
|
726
|
+
"utf8"
|
|
727
|
+
)
|
|
728
|
+
const partDir = path.join(storage.part, messageID)
|
|
729
|
+
fs.mkdirSync(partDir, { recursive: true })
|
|
730
|
+
fs.writeFileSync(
|
|
731
|
+
path.join(partDir, "part_1.json"),
|
|
732
|
+
JSON.stringify({
|
|
733
|
+
id: "part_1",
|
|
734
|
+
sessionID: mainSessionId,
|
|
735
|
+
messageID,
|
|
736
|
+
type: "tool",
|
|
737
|
+
callID: "call_1",
|
|
738
|
+
tool: "delegate_task",
|
|
739
|
+
state: {
|
|
740
|
+
status: "completed",
|
|
741
|
+
input: {
|
|
742
|
+
run_in_background: true,
|
|
743
|
+
description: "Model scan",
|
|
744
|
+
subagent_type: "explore",
|
|
745
|
+
},
|
|
746
|
+
},
|
|
747
|
+
}),
|
|
748
|
+
"utf8"
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
const projectID = "proj"
|
|
752
|
+
const sessDir = path.join(storage.session, projectID)
|
|
753
|
+
fs.mkdirSync(sessDir, { recursive: true })
|
|
754
|
+
fs.writeFileSync(
|
|
755
|
+
path.join(sessDir, "ses_child.json"),
|
|
756
|
+
JSON.stringify({
|
|
757
|
+
id: "ses_child",
|
|
758
|
+
projectID,
|
|
759
|
+
directory: "/tmp/project",
|
|
760
|
+
title: "Background: Model scan",
|
|
761
|
+
parentID: mainSessionId,
|
|
762
|
+
time: { created: 1500, updated: 1500 },
|
|
763
|
+
}),
|
|
764
|
+
"utf8"
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
const childMsgDir = path.join(storage.message, "ses_child")
|
|
768
|
+
fs.mkdirSync(childMsgDir, { recursive: true })
|
|
769
|
+
const childMsgId = "msg_child"
|
|
770
|
+
fs.writeFileSync(
|
|
771
|
+
path.join(childMsgDir, `${childMsgId}.json`),
|
|
772
|
+
JSON.stringify({
|
|
773
|
+
id: childMsgId,
|
|
774
|
+
sessionID: "ses_child",
|
|
775
|
+
role: "assistant",
|
|
776
|
+
time: { created: 2000 },
|
|
777
|
+
providerID: "openai",
|
|
778
|
+
modelID: "gpt-5.2",
|
|
779
|
+
}),
|
|
780
|
+
"utf8"
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
// #when
|
|
784
|
+
const rows = deriveBackgroundTasks({ storage, mainSessionId })
|
|
785
|
+
|
|
786
|
+
// #then
|
|
787
|
+
expect(rows.length).toBe(1)
|
|
788
|
+
expect(rows[0].lastModel).toBe("openai/gpt-5.2")
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
it("orders lastModel by time.created over file mtime", () => {
|
|
792
|
+
// #given
|
|
793
|
+
const storageRoot = mkStorageRoot()
|
|
794
|
+
const storage = getStorageRoots(storageRoot)
|
|
795
|
+
const mainSessionId = "ses_main"
|
|
796
|
+
|
|
797
|
+
const msgDir = path.join(storage.message, mainSessionId)
|
|
798
|
+
fs.mkdirSync(msgDir, { recursive: true })
|
|
799
|
+
const messageID = "msg_1"
|
|
800
|
+
fs.writeFileSync(
|
|
801
|
+
path.join(msgDir, `${messageID}.json`),
|
|
802
|
+
JSON.stringify({
|
|
803
|
+
id: messageID,
|
|
804
|
+
sessionID: mainSessionId,
|
|
805
|
+
role: "assistant",
|
|
806
|
+
time: { created: 1000 },
|
|
807
|
+
}),
|
|
808
|
+
"utf8"
|
|
809
|
+
)
|
|
810
|
+
const partDir = path.join(storage.part, messageID)
|
|
811
|
+
fs.mkdirSync(partDir, { recursive: true })
|
|
812
|
+
fs.writeFileSync(
|
|
813
|
+
path.join(partDir, "part_1.json"),
|
|
814
|
+
JSON.stringify({
|
|
815
|
+
id: "part_1",
|
|
816
|
+
sessionID: mainSessionId,
|
|
817
|
+
messageID,
|
|
818
|
+
type: "tool",
|
|
819
|
+
callID: "call_1",
|
|
820
|
+
tool: "delegate_task",
|
|
821
|
+
state: {
|
|
822
|
+
status: "completed",
|
|
823
|
+
input: {
|
|
824
|
+
run_in_background: true,
|
|
825
|
+
description: "Ordering scan",
|
|
826
|
+
subagent_type: "explore",
|
|
827
|
+
},
|
|
828
|
+
},
|
|
829
|
+
}),
|
|
830
|
+
"utf8"
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
const projectID = "proj"
|
|
834
|
+
const sessDir = path.join(storage.session, projectID)
|
|
835
|
+
fs.mkdirSync(sessDir, { recursive: true })
|
|
836
|
+
fs.writeFileSync(
|
|
837
|
+
path.join(sessDir, "ses_child.json"),
|
|
838
|
+
JSON.stringify({
|
|
839
|
+
id: "ses_child",
|
|
840
|
+
projectID,
|
|
841
|
+
directory: "/tmp/project",
|
|
842
|
+
title: "Background: Ordering scan",
|
|
843
|
+
parentID: mainSessionId,
|
|
844
|
+
time: { created: 1500, updated: 1500 },
|
|
845
|
+
}),
|
|
846
|
+
"utf8"
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
const childMsgDir = path.join(storage.message, "ses_child")
|
|
850
|
+
fs.mkdirSync(childMsgDir, { recursive: true })
|
|
851
|
+
const olderMsgId = "msg_older"
|
|
852
|
+
const newerMsgId = "msg_newer"
|
|
853
|
+
|
|
854
|
+
fs.writeFileSync(
|
|
855
|
+
path.join(childMsgDir, `${newerMsgId}.json`),
|
|
856
|
+
JSON.stringify({
|
|
857
|
+
id: newerMsgId,
|
|
858
|
+
sessionID: "ses_child",
|
|
859
|
+
role: "assistant",
|
|
860
|
+
time: { created: 3000 },
|
|
861
|
+
providerID: "openai",
|
|
862
|
+
modelID: "gpt-newer",
|
|
863
|
+
}),
|
|
864
|
+
"utf8"
|
|
865
|
+
)
|
|
866
|
+
fs.writeFileSync(
|
|
867
|
+
path.join(childMsgDir, `${olderMsgId}.json`),
|
|
868
|
+
JSON.stringify({
|
|
869
|
+
id: olderMsgId,
|
|
870
|
+
sessionID: "ses_child",
|
|
871
|
+
role: "assistant",
|
|
872
|
+
time: { created: 1000 },
|
|
873
|
+
providerID: "openai",
|
|
874
|
+
modelID: "gpt-older",
|
|
875
|
+
}),
|
|
876
|
+
"utf8"
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
// #when
|
|
880
|
+
const rows = deriveBackgroundTasks({ storage, mainSessionId })
|
|
881
|
+
|
|
882
|
+
// #then
|
|
883
|
+
expect(rows.length).toBe(1)
|
|
884
|
+
expect(rows[0].lastModel).toBe("openai/gpt-newer")
|
|
885
|
+
})
|
|
886
|
+
|
|
887
|
+
it("memoizes background session reads for shared sessionId", () => {
|
|
888
|
+
// #given
|
|
889
|
+
const storageRoot = mkStorageRoot()
|
|
890
|
+
const storage = getStorageRoots(storageRoot)
|
|
891
|
+
const mainSessionId = "ses_main"
|
|
892
|
+
|
|
893
|
+
// Clear any previous mock state
|
|
894
|
+
vi.clearAllMocks()
|
|
895
|
+
|
|
896
|
+
const msgDir = path.join(storage.message, mainSessionId)
|
|
897
|
+
fs.mkdirSync(msgDir, { recursive: true })
|
|
898
|
+
const messageID = "msg_1"
|
|
899
|
+
fs.writeFileSync(
|
|
900
|
+
path.join(msgDir, `${messageID}.json`),
|
|
901
|
+
JSON.stringify({
|
|
902
|
+
id: messageID,
|
|
903
|
+
sessionID: mainSessionId,
|
|
904
|
+
role: "assistant",
|
|
905
|
+
time: { created: 1000 },
|
|
906
|
+
}),
|
|
907
|
+
"utf8"
|
|
908
|
+
)
|
|
909
|
+
const partDir = path.join(storage.part, messageID)
|
|
910
|
+
fs.mkdirSync(partDir, { recursive: true })
|
|
911
|
+
fs.writeFileSync(
|
|
912
|
+
path.join(partDir, "part_1.json"),
|
|
913
|
+
JSON.stringify({
|
|
914
|
+
id: "part_1",
|
|
915
|
+
sessionID: mainSessionId,
|
|
916
|
+
messageID,
|
|
917
|
+
type: "tool",
|
|
918
|
+
callID: "call_1",
|
|
919
|
+
tool: "delegate_task",
|
|
920
|
+
state: {
|
|
921
|
+
status: "completed",
|
|
922
|
+
input: {
|
|
923
|
+
run_in_background: true,
|
|
924
|
+
description: "Memo scan",
|
|
925
|
+
subagent_type: "explore",
|
|
926
|
+
},
|
|
927
|
+
},
|
|
928
|
+
}),
|
|
929
|
+
"utf8"
|
|
930
|
+
)
|
|
931
|
+
fs.writeFileSync(
|
|
932
|
+
path.join(partDir, "part_2.json"),
|
|
933
|
+
JSON.stringify({
|
|
934
|
+
id: "part_2",
|
|
935
|
+
sessionID: mainSessionId,
|
|
936
|
+
messageID,
|
|
937
|
+
type: "tool",
|
|
938
|
+
callID: "call_2",
|
|
939
|
+
tool: "delegate_task",
|
|
940
|
+
state: {
|
|
941
|
+
status: "completed",
|
|
942
|
+
input: {
|
|
943
|
+
run_in_background: true,
|
|
944
|
+
description: "Memo scan",
|
|
945
|
+
subagent_type: "explore",
|
|
946
|
+
},
|
|
947
|
+
},
|
|
948
|
+
}),
|
|
949
|
+
"utf8"
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
const projectID = "proj"
|
|
953
|
+
const sessDir = path.join(storage.session, projectID)
|
|
954
|
+
fs.mkdirSync(sessDir, { recursive: true })
|
|
955
|
+
fs.writeFileSync(
|
|
956
|
+
path.join(sessDir, "ses_child.json"),
|
|
957
|
+
JSON.stringify({
|
|
958
|
+
id: "ses_child",
|
|
959
|
+
projectID,
|
|
960
|
+
directory: "/tmp/project",
|
|
961
|
+
title: "Background: Memo scan",
|
|
962
|
+
parentID: mainSessionId,
|
|
963
|
+
time: { created: 1500, updated: 1500 },
|
|
964
|
+
}),
|
|
965
|
+
"utf8"
|
|
966
|
+
)
|
|
967
|
+
|
|
968
|
+
const childMsgDir = path.join(storage.message, "ses_child")
|
|
969
|
+
fs.mkdirSync(childMsgDir, { recursive: true })
|
|
970
|
+
const childMsgId = "msg_child"
|
|
971
|
+
fs.writeFileSync(
|
|
972
|
+
path.join(childMsgDir, `${childMsgId}.json`),
|
|
973
|
+
JSON.stringify({
|
|
974
|
+
id: childMsgId,
|
|
975
|
+
sessionID: "ses_child",
|
|
976
|
+
role: "assistant",
|
|
977
|
+
time: { created: 2000 },
|
|
978
|
+
providerID: "openai",
|
|
979
|
+
modelID: "gpt-5.2",
|
|
980
|
+
}),
|
|
981
|
+
"utf8"
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
const readdirSync = vi.fn(fs.readdirSync)
|
|
985
|
+
const fsLike = {
|
|
986
|
+
readFileSync: fs.readFileSync,
|
|
987
|
+
readdirSync,
|
|
988
|
+
existsSync: fs.existsSync,
|
|
989
|
+
statSync: fs.statSync,
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// #when
|
|
993
|
+
const rows = deriveBackgroundTasks({ storage, mainSessionId, fs: fsLike })
|
|
994
|
+
|
|
995
|
+
// #then
|
|
996
|
+
const backgroundReads = readdirSync.mock.calls.filter((call) => call[0] === childMsgDir)
|
|
997
|
+
expect(rows.length).toBe(2)
|
|
998
|
+
expect(backgroundReads.length).toBe(1)
|
|
999
|
+
})
|
|
707
1000
|
})
|
|
@@ -2,6 +2,9 @@ import * as fs from "node:fs"
|
|
|
2
2
|
import * as path from "node:path"
|
|
3
3
|
import type { OpenCodeStorageRoots, SessionMetadata, StoredMessageMeta, StoredToolPart } from "./session"
|
|
4
4
|
import { getMessageDir } from "./session"
|
|
5
|
+
import { pickLatestModelString } from "./model"
|
|
6
|
+
|
|
7
|
+
type FsLike = Pick<typeof fs, "readFileSync" | "readdirSync" | "existsSync" | "statSync">
|
|
5
8
|
|
|
6
9
|
export type BackgroundTaskRow = {
|
|
7
10
|
id: string
|
|
@@ -10,6 +13,7 @@ export type BackgroundTaskRow = {
|
|
|
10
13
|
status: "queued" | "running" | "completed" | "error" | "unknown"
|
|
11
14
|
toolCalls: number | null
|
|
12
15
|
lastTool: string | null
|
|
16
|
+
lastModel: string | null
|
|
13
17
|
timeline: string
|
|
14
18
|
sessionId: string | null
|
|
15
19
|
}
|
|
@@ -24,31 +28,31 @@ function clampString(value: unknown, maxLen: number): string | null {
|
|
|
24
28
|
return s.length <= maxLen ? s : s.slice(0, maxLen)
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
function readJsonFile<T>(filePath: string): T | null {
|
|
31
|
+
function readJsonFile<T>(filePath: string, fsLike: FsLike): T | null {
|
|
28
32
|
try {
|
|
29
|
-
const content =
|
|
33
|
+
const content = fsLike.readFileSync(filePath, "utf8")
|
|
30
34
|
return JSON.parse(content) as T
|
|
31
35
|
} catch {
|
|
32
36
|
return null
|
|
33
37
|
}
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
function listJsonFiles(dir: string): string[] {
|
|
40
|
+
function listJsonFiles(dir: string, fsLike: FsLike): string[] {
|
|
37
41
|
try {
|
|
38
|
-
return
|
|
42
|
+
return fsLike.readdirSync(dir).filter((f) => f.endsWith(".json"))
|
|
39
43
|
} catch {
|
|
40
44
|
return []
|
|
41
45
|
}
|
|
42
46
|
}
|
|
43
47
|
|
|
44
|
-
function readToolPartsForMessage(storage: OpenCodeStorageRoots, messageID: string): StoredToolPart[] {
|
|
48
|
+
function readToolPartsForMessage(storage: OpenCodeStorageRoots, messageID: string, fsLike: FsLike): StoredToolPart[] {
|
|
45
49
|
const partDir = path.join(storage.part, messageID)
|
|
46
|
-
if (!
|
|
50
|
+
if (!fsLike.existsSync(partDir)) return []
|
|
47
51
|
|
|
48
|
-
const files = listJsonFiles(partDir).sort()
|
|
52
|
+
const files = listJsonFiles(partDir, fsLike).sort()
|
|
49
53
|
const parts: StoredToolPart[] = []
|
|
50
54
|
for (const f of files) {
|
|
51
|
-
const p = readJsonFile<StoredToolPart>(path.join(partDir, f))
|
|
55
|
+
const p = readJsonFile<StoredToolPart>(path.join(partDir, f), fsLike)
|
|
52
56
|
if (p && p.type === "tool" && typeof p.tool === "string" && p.state && typeof p.state === "object") {
|
|
53
57
|
parts.push(p)
|
|
54
58
|
}
|
|
@@ -56,14 +60,14 @@ function readToolPartsForMessage(storage: OpenCodeStorageRoots, messageID: strin
|
|
|
56
60
|
return parts
|
|
57
61
|
}
|
|
58
62
|
|
|
59
|
-
function readRecentMessageMetas(messageDir: string, maxMessages: number): StoredMessageMeta[] {
|
|
60
|
-
if (!messageDir || !
|
|
61
|
-
const files = listJsonFiles(messageDir)
|
|
63
|
+
function readRecentMessageMetas(messageDir: string, maxMessages: number, fsLike: FsLike): StoredMessageMeta[] {
|
|
64
|
+
if (!messageDir || !fsLike.existsSync(messageDir)) return []
|
|
65
|
+
const files = listJsonFiles(messageDir, fsLike)
|
|
62
66
|
.map((f) => ({
|
|
63
67
|
f,
|
|
64
68
|
mtime: (() => {
|
|
65
69
|
try {
|
|
66
|
-
return
|
|
70
|
+
return fsLike.statSync(path.join(messageDir, f)).mtimeMs
|
|
67
71
|
} catch {
|
|
68
72
|
return 0
|
|
69
73
|
}
|
|
@@ -74,22 +78,22 @@ function readRecentMessageMetas(messageDir: string, maxMessages: number): Stored
|
|
|
74
78
|
|
|
75
79
|
const metas: StoredMessageMeta[] = []
|
|
76
80
|
for (const item of files) {
|
|
77
|
-
const meta = readJsonFile<StoredMessageMeta>(path.join(messageDir, item.f))
|
|
81
|
+
const meta = readJsonFile<StoredMessageMeta>(path.join(messageDir, item.f), fsLike)
|
|
78
82
|
if (meta && typeof meta.id === "string") metas.push(meta)
|
|
79
83
|
}
|
|
80
84
|
return metas
|
|
81
85
|
}
|
|
82
86
|
|
|
83
|
-
export function readAllSessionMetas(sessionStorage: string): SessionMetadata[] {
|
|
84
|
-
if (!
|
|
87
|
+
export function readAllSessionMetas(sessionStorage: string, fsLike: FsLike = fs): SessionMetadata[] {
|
|
88
|
+
if (!fsLike.existsSync(sessionStorage)) return []
|
|
85
89
|
const metas: SessionMetadata[] = []
|
|
86
90
|
try {
|
|
87
|
-
const projectDirs =
|
|
91
|
+
const projectDirs = fsLike.readdirSync(sessionStorage, { withFileTypes: true })
|
|
88
92
|
for (const d of projectDirs) {
|
|
89
93
|
if (!d.isDirectory()) continue
|
|
90
94
|
const projectPath = path.join(sessionStorage, d.name)
|
|
91
|
-
for (const file of listJsonFiles(projectPath)) {
|
|
92
|
-
const meta = readJsonFile<SessionMetadata>(path.join(projectPath, file))
|
|
95
|
+
for (const file of listJsonFiles(projectPath, fsLike)) {
|
|
96
|
+
const meta = readJsonFile<SessionMetadata>(path.join(projectPath, file), fsLike)
|
|
93
97
|
if (meta && typeof meta.id === "string") metas.push(meta)
|
|
94
98
|
}
|
|
95
99
|
}
|
|
@@ -152,9 +156,11 @@ function findTaskSessionId(opts: {
|
|
|
152
156
|
return candidates[0]?.id ?? null
|
|
153
157
|
}
|
|
154
158
|
|
|
155
|
-
function deriveBackgroundSessionStats(
|
|
156
|
-
|
|
157
|
-
|
|
159
|
+
function deriveBackgroundSessionStats(
|
|
160
|
+
storage: OpenCodeStorageRoots,
|
|
161
|
+
metas: StoredMessageMeta[],
|
|
162
|
+
fsLike: FsLike
|
|
163
|
+
): { toolCalls: number; lastTool: string | null; lastUpdateAt: number | null } {
|
|
158
164
|
let toolCalls = 0
|
|
159
165
|
let lastTool: string | null = null
|
|
160
166
|
let lastUpdateAt: number | null = null
|
|
@@ -170,7 +176,7 @@ function deriveBackgroundSessionStats(storage: OpenCodeStorageRoots, sessionId:
|
|
|
170
176
|
for (const meta of ordered) {
|
|
171
177
|
const created = meta.time?.created
|
|
172
178
|
if (typeof created === "number") lastUpdateAt = created
|
|
173
|
-
const parts = readToolPartsForMessage(storage, meta.id)
|
|
179
|
+
const parts = readToolPartsForMessage(storage, meta.id, fsLike)
|
|
174
180
|
for (const p of parts) {
|
|
175
181
|
toolCalls += 1
|
|
176
182
|
lastTool = p.tool
|
|
@@ -211,11 +217,42 @@ export function deriveBackgroundTasks(opts: {
|
|
|
211
217
|
storage: OpenCodeStorageRoots
|
|
212
218
|
mainSessionId: string
|
|
213
219
|
nowMs?: number
|
|
220
|
+
fs?: FsLike
|
|
214
221
|
}): BackgroundTaskRow[] {
|
|
222
|
+
const fsLike: FsLike = opts.fs ?? fs
|
|
215
223
|
const nowMs = opts.nowMs ?? Date.now()
|
|
216
224
|
const messageDir = getMessageDir(opts.storage.message, opts.mainSessionId)
|
|
217
|
-
const metas = readRecentMessageMetas(messageDir, 200)
|
|
218
|
-
const allSessionMetas = readAllSessionMetas(opts.storage.session)
|
|
225
|
+
const metas = readRecentMessageMetas(messageDir, 200, fsLike)
|
|
226
|
+
const allSessionMetas = readAllSessionMetas(opts.storage.session, fsLike)
|
|
227
|
+
const backgroundMessageCache = new Map<string, StoredMessageMeta[]>()
|
|
228
|
+
const backgroundStatsCache = new Map<string, { toolCalls: number; lastTool: string | null; lastUpdateAt: number | null }>()
|
|
229
|
+
const backgroundModelCache = new Map<string, string | null>()
|
|
230
|
+
|
|
231
|
+
const readBackgroundMetas = (sessionId: string): StoredMessageMeta[] => {
|
|
232
|
+
const cached = backgroundMessageCache.get(sessionId)
|
|
233
|
+
if (cached) return cached
|
|
234
|
+
const backgroundMessageDir = getMessageDir(opts.storage.message, sessionId)
|
|
235
|
+
const recent = readRecentMessageMetas(backgroundMessageDir, 200, fsLike)
|
|
236
|
+
backgroundMessageCache.set(sessionId, recent)
|
|
237
|
+
return recent
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const readBackgroundStats = (sessionId: string) => {
|
|
241
|
+
const cached = backgroundStatsCache.get(sessionId)
|
|
242
|
+
if (cached) return cached
|
|
243
|
+
const recent = readBackgroundMetas(sessionId)
|
|
244
|
+
const stats = deriveBackgroundSessionStats(opts.storage, recent, fsLike)
|
|
245
|
+
backgroundStatsCache.set(sessionId, stats)
|
|
246
|
+
return stats
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const readBackgroundModel = (sessionId: string): string | null => {
|
|
250
|
+
if (backgroundModelCache.has(sessionId)) return backgroundModelCache.get(sessionId) ?? null
|
|
251
|
+
const recent = readBackgroundMetas(sessionId)
|
|
252
|
+
const model = pickLatestModelString(recent as unknown[])
|
|
253
|
+
backgroundModelCache.set(sessionId, model)
|
|
254
|
+
return model
|
|
255
|
+
}
|
|
219
256
|
|
|
220
257
|
const rows: BackgroundTaskRow[] = []
|
|
221
258
|
|
|
@@ -225,7 +262,7 @@ export function deriveBackgroundTasks(opts: {
|
|
|
225
262
|
const startedAt = meta.time?.created ?? null
|
|
226
263
|
if (typeof startedAt !== "number") continue
|
|
227
264
|
|
|
228
|
-
const parts = readToolPartsForMessage(opts.storage, meta.id)
|
|
265
|
+
const parts = readToolPartsForMessage(opts.storage, meta.id, fsLike)
|
|
229
266
|
for (const part of parts) {
|
|
230
267
|
if (part.tool !== "delegate_task") continue
|
|
231
268
|
if (!part.state || typeof part.state !== "object") continue
|
|
@@ -258,7 +295,7 @@ export function deriveBackgroundTasks(opts: {
|
|
|
258
295
|
if (typeof resume === "string" && resume.trim() !== "") {
|
|
259
296
|
// Check if resumed session exists (has readable messages dir)
|
|
260
297
|
const resumeMessageDir = getMessageDir(opts.storage.message, resume.trim())
|
|
261
|
-
if (
|
|
298
|
+
if (fsLike.existsSync(resumeMessageDir) && fsLike.readdirSync(resumeMessageDir).length > 0) {
|
|
262
299
|
backgroundSessionId = resume.trim()
|
|
263
300
|
}
|
|
264
301
|
}
|
|
@@ -283,8 +320,9 @@ export function deriveBackgroundTasks(opts: {
|
|
|
283
320
|
}
|
|
284
321
|
|
|
285
322
|
const stats = backgroundSessionId
|
|
286
|
-
?
|
|
323
|
+
? readBackgroundStats(backgroundSessionId)
|
|
287
324
|
: { toolCalls: 0, lastTool: null, lastUpdateAt: startedAt }
|
|
325
|
+
const lastModel = backgroundSessionId ? readBackgroundModel(backgroundSessionId) : null
|
|
288
326
|
|
|
289
327
|
// Best-effort status: if background session exists and has any tool calls, treat as running unless idle.
|
|
290
328
|
let status: BackgroundTaskRow["status"] = "unknown"
|
|
@@ -305,7 +343,8 @@ export function deriveBackgroundTasks(opts: {
|
|
|
305
343
|
status,
|
|
306
344
|
toolCalls: backgroundSessionId ? stats.toolCalls : null,
|
|
307
345
|
lastTool: stats.lastTool,
|
|
308
|
-
|
|
346
|
+
lastModel,
|
|
347
|
+
timeline: status === "unknown" ? "" : formatTimeline(startedAt, timelineEndMs),
|
|
309
348
|
sessionId: backgroundSessionId,
|
|
310
349
|
})
|
|
311
350
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
type ModelParts = {
|
|
2
|
+
providerID?: string
|
|
3
|
+
modelID?: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
7
|
+
return typeof value === "object" && value !== null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function readString(value: unknown): string | null {
|
|
11
|
+
if (typeof value !== "string") return null
|
|
12
|
+
const trimmed = value.trim()
|
|
13
|
+
return trimmed.length > 0 ? trimmed : null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readModelParts(value: Record<string, unknown>): ModelParts {
|
|
17
|
+
const providerID =
|
|
18
|
+
readString(value.providerID) ??
|
|
19
|
+
readString(value.providerId) ??
|
|
20
|
+
readString(value.provider_id)
|
|
21
|
+
const modelID =
|
|
22
|
+
readString(value.modelID) ??
|
|
23
|
+
readString(value.modelId) ??
|
|
24
|
+
readString(value.model_id)
|
|
25
|
+
|
|
26
|
+
return { providerID: providerID ?? undefined, modelID: modelID ?? undefined }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function extractModelString(meta: unknown): string | null {
|
|
30
|
+
if (!isRecord(meta)) return null
|
|
31
|
+
|
|
32
|
+
const direct = readModelParts(meta)
|
|
33
|
+
if (direct.providerID && direct.modelID) return `${direct.providerID}/${direct.modelID}`
|
|
34
|
+
|
|
35
|
+
const nested = meta.model
|
|
36
|
+
if (isRecord(nested)) {
|
|
37
|
+
const nestedParts = readModelParts(nested)
|
|
38
|
+
if (nestedParts.providerID && nestedParts.modelID) {
|
|
39
|
+
return `${nestedParts.providerID}/${nestedParts.modelID}`
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type ModelCandidate = {
|
|
47
|
+
created: number
|
|
48
|
+
id: string
|
|
49
|
+
role: string | null
|
|
50
|
+
model: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function pickLatestModelString(metas: Array<unknown>): string | null {
|
|
54
|
+
const candidates: ModelCandidate[] = []
|
|
55
|
+
|
|
56
|
+
for (const meta of metas) {
|
|
57
|
+
if (!isRecord(meta)) continue
|
|
58
|
+
const model = extractModelString(meta)
|
|
59
|
+
if (!model) continue
|
|
60
|
+
|
|
61
|
+
const created = typeof meta.time === "object" && meta.time !== null && typeof (meta.time as { created?: unknown }).created === "number"
|
|
62
|
+
? (meta.time as { created: number }).created
|
|
63
|
+
: 0
|
|
64
|
+
const id = readString(meta.id) ?? ""
|
|
65
|
+
const role = readString(meta.role)
|
|
66
|
+
|
|
67
|
+
candidates.push({ created, id, role, model })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (candidates.length === 0) return null
|
|
71
|
+
|
|
72
|
+
candidates.sort((a, b) => {
|
|
73
|
+
if (b.created !== a.created) return b.created - a.created
|
|
74
|
+
return b.id.localeCompare(a.id)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const assistant = candidates.find((candidate) => candidate.role === "assistant")
|
|
78
|
+
return assistant?.model ?? candidates[0]?.model ?? null
|
|
79
|
+
}
|