ocwatch 0.4.0 → 0.5.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/package.json +1 -1
- package/src/client/dist/assets/{index-BYMVif3u.js → index-CzAaOuyw.js} +14 -14
- package/src/client/dist/index.html +1 -1
- package/src/server/__tests__/helpers/testDb.ts +220 -0
- package/src/server/index.ts +20 -5
- package/src/server/logic/activityLogic.ts +260 -0
- package/src/server/logic/index.ts +2 -0
- package/src/server/logic/sessionLogic.ts +107 -0
- package/src/server/routes/parts.ts +9 -7
- package/src/server/routes/poll.ts +34 -45
- package/src/server/routes/projects.ts +4 -4
- package/src/server/routes/sessions.ts +107 -68
- package/src/server/routes/sse.ts +10 -4
- package/src/server/services/parsing.ts +211 -0
- package/src/server/services/pollService.ts +292 -114
- package/src/server/services/sessionService.ts +178 -106
- package/src/server/storage/db.ts +71 -0
- package/src/server/storage/index.ts +22 -0
- package/src/server/storage/queries.ts +325 -0
- package/src/server/utils/projectResolver.ts +2 -2
- package/src/server/utils/sessionStatus.ts +4 -70
- package/src/server/watcher.ts +187 -82
- package/src/shared/constants.ts +1 -0
- package/src/shared/types/index.ts +39 -5
- package/src/server/storage/messageParser.ts +0 -169
- package/src/server/storage/partParser.ts +0 -532
- package/src/server/storage/sessionParser.ts +0 -180
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<title>client</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-CzAaOuyw.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-BIu7r5_5.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test helper for creating SQLite fixture databases.
|
|
3
|
+
*
|
|
4
|
+
* Creates a real SQLite DB at the expected XDG path so that the
|
|
5
|
+
* db.ts singleton picks it up via `XDG_DATA_HOME`.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* 1. Call `setupTestDb(testDir)` in beforeEach — sets XDG_DATA_HOME, creates DB, resets singleton
|
|
9
|
+
* 2. Use `insertSession()` / `insertMessage()` etc. to populate fixtures
|
|
10
|
+
* 3. Call `teardownTestDb(originalXdg)` in afterEach — closes DB, resets singleton, restores env
|
|
11
|
+
*/
|
|
12
|
+
import { Database } from "bun:sqlite";
|
|
13
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { closeDb } from "../../storage/db";
|
|
16
|
+
import { invalidatePollCache } from "../../services/pollService";
|
|
17
|
+
|
|
18
|
+
const SCHEMA_SQL = `
|
|
19
|
+
CREATE TABLE IF NOT EXISTS project (
|
|
20
|
+
id TEXT PRIMARY KEY,
|
|
21
|
+
name TEXT,
|
|
22
|
+
worktree TEXT NOT NULL DEFAULT '',
|
|
23
|
+
vcs TEXT,
|
|
24
|
+
commands TEXT,
|
|
25
|
+
sandboxes TEXT,
|
|
26
|
+
time_created INTEGER NOT NULL DEFAULT 0,
|
|
27
|
+
time_updated INTEGER NOT NULL DEFAULT 0
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
CREATE TABLE IF NOT EXISTS session (
|
|
31
|
+
id TEXT PRIMARY KEY,
|
|
32
|
+
project_id TEXT NOT NULL,
|
|
33
|
+
parent_id TEXT,
|
|
34
|
+
slug TEXT,
|
|
35
|
+
directory TEXT NOT NULL DEFAULT '',
|
|
36
|
+
title TEXT NOT NULL DEFAULT '',
|
|
37
|
+
version TEXT,
|
|
38
|
+
time_created INTEGER NOT NULL DEFAULT 0,
|
|
39
|
+
time_updated INTEGER NOT NULL DEFAULT 0
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
CREATE TABLE IF NOT EXISTS message (
|
|
43
|
+
id TEXT PRIMARY KEY,
|
|
44
|
+
session_id TEXT NOT NULL,
|
|
45
|
+
time_created INTEGER NOT NULL DEFAULT 0,
|
|
46
|
+
time_updated INTEGER NOT NULL DEFAULT 0,
|
|
47
|
+
data TEXT NOT NULL DEFAULT '{}'
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE TABLE IF NOT EXISTS part (
|
|
51
|
+
id TEXT PRIMARY KEY,
|
|
52
|
+
message_id TEXT NOT NULL,
|
|
53
|
+
session_id TEXT NOT NULL,
|
|
54
|
+
time_created INTEGER NOT NULL DEFAULT 0,
|
|
55
|
+
time_updated INTEGER NOT NULL DEFAULT 0,
|
|
56
|
+
data TEXT NOT NULL DEFAULT '{}'
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE TABLE IF NOT EXISTS todo (
|
|
60
|
+
session_id TEXT NOT NULL,
|
|
61
|
+
content TEXT NOT NULL DEFAULT '',
|
|
62
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
63
|
+
priority TEXT NOT NULL DEFAULT 'medium',
|
|
64
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
65
|
+
time_created INTEGER NOT NULL DEFAULT 0,
|
|
66
|
+
time_updated INTEGER NOT NULL DEFAULT 0
|
|
67
|
+
);
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
export interface TestSessionFixture {
|
|
71
|
+
id: string;
|
|
72
|
+
projectId: string;
|
|
73
|
+
directory: string;
|
|
74
|
+
title?: string;
|
|
75
|
+
parentId?: string | null;
|
|
76
|
+
timeCreated?: number;
|
|
77
|
+
timeUpdated?: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface TestMessageFixture {
|
|
81
|
+
id: string;
|
|
82
|
+
sessionId: string;
|
|
83
|
+
role?: string;
|
|
84
|
+
agent?: string;
|
|
85
|
+
timeCreated?: number;
|
|
86
|
+
timeUpdated?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Returns the expected DB file path for a given test directory.
|
|
91
|
+
*/
|
|
92
|
+
export function getTestDbPath(testDir: string): string {
|
|
93
|
+
return join(testDir, "opencode", "opencode.db");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Creates a test SQLite database with the full schema at the XDG-expected path.
|
|
98
|
+
* Returns the Database instance (writable) for inserting fixtures.
|
|
99
|
+
*/
|
|
100
|
+
export async function createTestDatabase(testDir: string): Promise<Database> {
|
|
101
|
+
const dbDir = join(testDir, "opencode");
|
|
102
|
+
await mkdir(dbDir, { recursive: true });
|
|
103
|
+
|
|
104
|
+
const dbPath = getTestDbPath(testDir);
|
|
105
|
+
const db = new Database(dbPath);
|
|
106
|
+
db.exec(SCHEMA_SQL);
|
|
107
|
+
return db;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Full setup: sets XDG_DATA_HOME, resets db singleton, creates test DB.
|
|
112
|
+
* Returns the writable Database instance for inserting fixtures.
|
|
113
|
+
*/
|
|
114
|
+
export async function setupTestDb(testDir: string): Promise<Database> {
|
|
115
|
+
await rm(testDir, { recursive: true, force: true });
|
|
116
|
+
await mkdir(testDir, { recursive: true });
|
|
117
|
+
|
|
118
|
+
// Reset singleton so it picks up the new XDG path
|
|
119
|
+
closeDb();
|
|
120
|
+
process.env.XDG_DATA_HOME = testDir;
|
|
121
|
+
invalidatePollCache();
|
|
122
|
+
|
|
123
|
+
return createTestDatabase(testDir);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Full teardown: closes writable DB, resets singleton, cleans up, restores env.
|
|
128
|
+
*/
|
|
129
|
+
export async function teardownTestDb(
|
|
130
|
+
testDb: Database | null,
|
|
131
|
+
testDir: string,
|
|
132
|
+
originalXdg: string | undefined,
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
if (testDb) {
|
|
135
|
+
try { testDb.close(); } catch { /* already closed */ }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
closeDb();
|
|
139
|
+
invalidatePollCache();
|
|
140
|
+
|
|
141
|
+
await rm(testDir, { recursive: true, force: true });
|
|
142
|
+
|
|
143
|
+
if (originalXdg === undefined) {
|
|
144
|
+
delete process.env.XDG_DATA_HOME;
|
|
145
|
+
} else {
|
|
146
|
+
process.env.XDG_DATA_HOME = originalXdg;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Insert a session fixture into the test database.
|
|
152
|
+
*/
|
|
153
|
+
export function insertSession(db: Database, fixture: TestSessionFixture): void {
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
db.run(
|
|
156
|
+
`INSERT INTO session (id, project_id, parent_id, slug, directory, title, time_created, time_updated)
|
|
157
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
158
|
+
[
|
|
159
|
+
fixture.id,
|
|
160
|
+
fixture.projectId,
|
|
161
|
+
fixture.parentId ?? null,
|
|
162
|
+
fixture.id,
|
|
163
|
+
fixture.directory,
|
|
164
|
+
fixture.title ?? `Session ${fixture.id}`,
|
|
165
|
+
fixture.timeCreated ?? now,
|
|
166
|
+
fixture.timeUpdated ?? now,
|
|
167
|
+
],
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Insert a message fixture into the test database.
|
|
173
|
+
*/
|
|
174
|
+
export function insertMessage(db: Database, fixture: TestMessageFixture): void {
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
const data = JSON.stringify({
|
|
177
|
+
role: fixture.role ?? "assistant",
|
|
178
|
+
agent: fixture.agent ?? null,
|
|
179
|
+
});
|
|
180
|
+
db.run(
|
|
181
|
+
`INSERT INTO message (id, session_id, time_created, time_updated, data)
|
|
182
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
183
|
+
[
|
|
184
|
+
fixture.id,
|
|
185
|
+
fixture.sessionId,
|
|
186
|
+
fixture.timeCreated ?? now,
|
|
187
|
+
fixture.timeUpdated ?? now,
|
|
188
|
+
data,
|
|
189
|
+
],
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Write a boulder.json + plan markdown in a project directory.
|
|
195
|
+
* (Boulder is filesystem-based, not in SQLite.)
|
|
196
|
+
*/
|
|
197
|
+
export async function writeBoulderFixture(
|
|
198
|
+
projectDirectory: string,
|
|
199
|
+
planName: string,
|
|
200
|
+
): Promise<void> {
|
|
201
|
+
const sisyphusDir = join(projectDirectory, ".sisyphus");
|
|
202
|
+
const plansDir = join(sisyphusDir, "plans");
|
|
203
|
+
await mkdir(plansDir, { recursive: true });
|
|
204
|
+
|
|
205
|
+
await writeFile(
|
|
206
|
+
join(plansDir, `${planName}.md`),
|
|
207
|
+
["# Test Plan", "", "- [x] Complete fixture setup", "- [ ] Keep testing"].join("\n"),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
await writeFile(
|
|
211
|
+
join(sisyphusDir, "boulder.json"),
|
|
212
|
+
JSON.stringify({
|
|
213
|
+
active_plan: `.sisyphus/plans/${planName}.md`,
|
|
214
|
+
session_ids: [],
|
|
215
|
+
status: "in-progress",
|
|
216
|
+
started_at: new Date().toISOString(),
|
|
217
|
+
plan_name: planName,
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
220
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { errorHandler, notFoundHandler } from "./middleware/error";
|
|
|
8
8
|
import { registerRoutes } from "./routes";
|
|
9
9
|
import { parseArgs, printHelp, openBrowser } from "./cli";
|
|
10
10
|
import { getGlobalWatcher, closeAllSSEConnections } from "./routes/sse";
|
|
11
|
-
import { listAllSessions } from "./storage
|
|
11
|
+
import { listAllSessions, closeDb } from "./storage";
|
|
12
12
|
|
|
13
13
|
const clientDistPath = join(import.meta.dir, "..", "client", "dist");
|
|
14
14
|
const flags = parseArgs();
|
|
@@ -23,7 +23,7 @@ function normalizeDirectoryPath(pathValue: string): string {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
async function resolveDefaultProjectId(projectPath: string): Promise<string | undefined> {
|
|
26
|
-
const knownSessions =
|
|
26
|
+
const knownSessions = listAllSessions();
|
|
27
27
|
const requestedPath = normalizeDirectoryPath(projectPath);
|
|
28
28
|
const seenProjectDirectories = new Map<string, string>();
|
|
29
29
|
|
|
@@ -112,8 +112,21 @@ export default {
|
|
|
112
112
|
|
|
113
113
|
function shutdown() {
|
|
114
114
|
console.log("\n🛑 Shutting down gracefully...");
|
|
115
|
-
try {
|
|
116
|
-
|
|
115
|
+
try {
|
|
116
|
+
getGlobalWatcher().stop();
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.warn('[shutdown] Failed to stop watcher:', error instanceof Error ? error.message : error);
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
closeAllSSEConnections();
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.warn('[shutdown] Failed to close SSE connections:', error instanceof Error ? error.message : error);
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
closeDb();
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.warn('[shutdown] Failed to close database:', error instanceof Error ? error.message : error);
|
|
129
|
+
}
|
|
117
130
|
process.exit(0);
|
|
118
131
|
}
|
|
119
132
|
|
|
@@ -125,5 +138,7 @@ if (flags.noBrowser) {
|
|
|
125
138
|
console.log(`📡 API ready for Vite dev server`);
|
|
126
139
|
} else {
|
|
127
140
|
console.log(`📋 Press Ctrl+C to stop`);
|
|
128
|
-
openBrowser(url).catch(() => {
|
|
141
|
+
openBrowser(url).catch((error) => {
|
|
142
|
+
console.warn('[ocwatch] Failed to open browser:', error instanceof Error ? error.message : error);
|
|
143
|
+
});
|
|
129
144
|
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import type { PartMeta, SessionActivityType, SessionStatus } from "../../shared/types";
|
|
2
|
+
|
|
3
|
+
const MAX_PATH_LENGTH = 40;
|
|
4
|
+
|
|
5
|
+
function truncatePath(path: string): string {
|
|
6
|
+
if (path.length <= MAX_PATH_LENGTH) {
|
|
7
|
+
return path;
|
|
8
|
+
}
|
|
9
|
+
return "..." + path.slice(-MAX_PATH_LENGTH + 3);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const TOOL_DISPLAY_NAMES: Record<string, string> = {
|
|
13
|
+
read: "Reading",
|
|
14
|
+
write: "Writing",
|
|
15
|
+
edit: "Editing",
|
|
16
|
+
bash: "Running",
|
|
17
|
+
grep: "Searching",
|
|
18
|
+
glob: "Finding",
|
|
19
|
+
task: "Delegating",
|
|
20
|
+
webfetch: "Fetching",
|
|
21
|
+
agent: "Agent",
|
|
22
|
+
subtask: "Subtask",
|
|
23
|
+
compaction: "Context Compaction",
|
|
24
|
+
file: "File",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function getToolDisplayName(tool: string): string {
|
|
28
|
+
const normalized = tool.replace(/^mcp_/, "").toLowerCase();
|
|
29
|
+
return TOOL_DISPLAY_NAMES[normalized] || tool;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatCurrentAction(part: PartMeta): string | null {
|
|
33
|
+
if (!part.tool) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const toolName = getToolDisplayName(part.tool);
|
|
38
|
+
|
|
39
|
+
if (part.tool === "task" || part.tool === "delegate_task") {
|
|
40
|
+
const input = part.input as { description?: string; subagent_type?: string } | undefined;
|
|
41
|
+
const desc = input?.description;
|
|
42
|
+
const agentType = input?.subagent_type;
|
|
43
|
+
if (desc && agentType) return `${desc} (${agentType})`;
|
|
44
|
+
if (desc) return desc;
|
|
45
|
+
if (agentType) return `Delegating (${agentType})`;
|
|
46
|
+
return "Delegating task";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (part.tool === "agent" || part.tool === "subtask") {
|
|
50
|
+
const input = part.input as { description?: string; subagent_type?: string; name?: string } | undefined;
|
|
51
|
+
const desc = input?.description;
|
|
52
|
+
const name = input?.name;
|
|
53
|
+
const agentType = input?.subagent_type;
|
|
54
|
+
if (desc) return desc;
|
|
55
|
+
if (name) return `${toolName}: ${name}`;
|
|
56
|
+
if (agentType) return `${toolName} (${agentType})`;
|
|
57
|
+
return toolName;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (part.tool === "compaction") {
|
|
61
|
+
return "Compacting context";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (part.tool === "todowrite") {
|
|
65
|
+
const input = part.input as { todos?: Array<{ content?: string }> } | undefined;
|
|
66
|
+
const todos = input?.todos;
|
|
67
|
+
if (!todos || todos.length === 0) return "Cleared todos";
|
|
68
|
+
const preview = todos
|
|
69
|
+
.slice(0, 2)
|
|
70
|
+
.map(t => (t.content || "").slice(0, 30))
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
.join(", ");
|
|
73
|
+
return `Updated ${todos.length} todo${todos.length !== 1 ? "s" : ""}: ${preview}${todos.length > 2 ? "..." : ""}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (part.tool === "todoread") {
|
|
77
|
+
return "Reading todos";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (part.input) {
|
|
81
|
+
if (part.input.filePath) {
|
|
82
|
+
return `${toolName} ${truncatePath(part.input.filePath)}`;
|
|
83
|
+
}
|
|
84
|
+
if (part.input.command) {
|
|
85
|
+
const cmd = part.input.command.length > 30
|
|
86
|
+
? part.input.command.slice(0, 27) + "..."
|
|
87
|
+
: part.input.command;
|
|
88
|
+
return `${toolName} ${cmd}`;
|
|
89
|
+
}
|
|
90
|
+
if (part.input.pattern) {
|
|
91
|
+
return `${toolName} for "${part.input.pattern}"`;
|
|
92
|
+
}
|
|
93
|
+
if (part.input.url) {
|
|
94
|
+
return `${toolName} ${truncatePath(part.input.url)}`;
|
|
95
|
+
}
|
|
96
|
+
if (part.input.query) {
|
|
97
|
+
return `${toolName} "${part.input.query}"`;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (part.title) {
|
|
102
|
+
return part.title;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return toolName;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const ACTIVE_TOOL_STATES = ["pending", "running", "in_progress"];
|
|
109
|
+
|
|
110
|
+
export function isPendingToolCall(part: PartMeta): boolean {
|
|
111
|
+
if (!part.tool || part.type !== "tool") {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!part.state) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return ACTIVE_TOOL_STATES.includes(part.state);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface SessionActivityState {
|
|
123
|
+
hasPendingToolCall: boolean;
|
|
124
|
+
pendingCount: number;
|
|
125
|
+
completedCount: number;
|
|
126
|
+
lastToolCompletedAt: Date | null;
|
|
127
|
+
isReasoning: boolean;
|
|
128
|
+
reasoningPreview: string | null;
|
|
129
|
+
patchFilesCount: number;
|
|
130
|
+
stepFinishReason: "stop" | "tool-calls" | null;
|
|
131
|
+
activeToolNames: string[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function getSessionActivityState(parts: PartMeta[]): SessionActivityState {
|
|
135
|
+
let pendingCount = 0;
|
|
136
|
+
let completedCount = 0;
|
|
137
|
+
let lastToolCompletedAt: Date | null = null;
|
|
138
|
+
let isReasoning = false;
|
|
139
|
+
let reasoningPreview: string | null = null;
|
|
140
|
+
let patchFilesCount = 0;
|
|
141
|
+
let stepFinishReason: "stop" | "tool-calls" | null = null;
|
|
142
|
+
const activeToolNames: string[] = [];
|
|
143
|
+
|
|
144
|
+
const sortedParts = [...parts].sort((a, b) => {
|
|
145
|
+
const timeA = a.startedAt?.getTime() || 0;
|
|
146
|
+
const timeB = b.startedAt?.getTime() || 0;
|
|
147
|
+
return timeB - timeA;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
for (const part of sortedParts) {
|
|
151
|
+
if (part.type === "tool" && part.tool) {
|
|
152
|
+
if (isPendingToolCall(part)) {
|
|
153
|
+
pendingCount++;
|
|
154
|
+
activeToolNames.push(part.tool.replace(/^mcp_/, ""));
|
|
155
|
+
} else if (part.state === "completed") {
|
|
156
|
+
completedCount++;
|
|
157
|
+
if (part.completedAt && (!lastToolCompletedAt || part.completedAt > lastToolCompletedAt)) {
|
|
158
|
+
lastToolCompletedAt = part.completedAt;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (part.type === "reasoning" && part.reasoningText && !isReasoning) {
|
|
164
|
+
isReasoning = true;
|
|
165
|
+
const text = part.reasoningText.trim();
|
|
166
|
+
reasoningPreview = text.length > 40 ? text.slice(0, 37) + "..." : text;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (part.type === "patch" && part.patchFiles && !part.completedAt) {
|
|
170
|
+
patchFilesCount += part.patchFiles.length;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (part.type === "step-finish" && part.stepFinishReason && !stepFinishReason) {
|
|
174
|
+
stepFinishReason = part.stepFinishReason;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
hasPendingToolCall: pendingCount > 0,
|
|
180
|
+
pendingCount,
|
|
181
|
+
completedCount,
|
|
182
|
+
lastToolCompletedAt,
|
|
183
|
+
isReasoning,
|
|
184
|
+
reasoningPreview,
|
|
185
|
+
patchFilesCount,
|
|
186
|
+
stepFinishReason,
|
|
187
|
+
activeToolNames,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function deriveActivityType(
|
|
192
|
+
activityState: SessionActivityState,
|
|
193
|
+
lastAssistantFinished: boolean,
|
|
194
|
+
isSubagent: boolean,
|
|
195
|
+
status: SessionStatus,
|
|
196
|
+
waitingReason?: "user" | "children"
|
|
197
|
+
): SessionActivityType {
|
|
198
|
+
if (status === "completed") {
|
|
199
|
+
return "idle";
|
|
200
|
+
}
|
|
201
|
+
if ((waitingReason === "user" || (!waitingReason && lastAssistantFinished)) && !isSubagent && status === "waiting") {
|
|
202
|
+
return "waiting-user";
|
|
203
|
+
}
|
|
204
|
+
if (activityState.pendingCount > 0) {
|
|
205
|
+
return "tool";
|
|
206
|
+
}
|
|
207
|
+
if (activityState.isReasoning) {
|
|
208
|
+
return "reasoning";
|
|
209
|
+
}
|
|
210
|
+
if (activityState.patchFilesCount > 0) {
|
|
211
|
+
return "patch";
|
|
212
|
+
}
|
|
213
|
+
if (activityState.stepFinishReason === "tool-calls") {
|
|
214
|
+
return "waiting-tools";
|
|
215
|
+
}
|
|
216
|
+
return "idle";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function generateActivityMessage(
|
|
220
|
+
activityState: SessionActivityState,
|
|
221
|
+
lastAssistantFinished: boolean,
|
|
222
|
+
isSubagent: boolean,
|
|
223
|
+
status: SessionStatus,
|
|
224
|
+
pendingPart?: PartMeta,
|
|
225
|
+
waitingReason?: "user" | "children"
|
|
226
|
+
): string | null {
|
|
227
|
+
if (status === "completed") {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
if ((waitingReason === "user" || (!waitingReason && lastAssistantFinished)) && !isSubagent && status === "waiting") {
|
|
231
|
+
return "Waiting for user input";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (activityState.pendingCount > 1) {
|
|
235
|
+
const toolNames = activityState.activeToolNames.slice(0, 3).join(", ");
|
|
236
|
+
const firstToolAction = pendingPart ? formatCurrentAction(pendingPart) : null;
|
|
237
|
+
if (firstToolAction) {
|
|
238
|
+
return `Running ${activityState.pendingCount} tools (${firstToolAction})`;
|
|
239
|
+
}
|
|
240
|
+
return `Running ${activityState.pendingCount} tools: ${toolNames}${activityState.activeToolNames.length > 3 ? "..." : ""}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (activityState.pendingCount === 1 && pendingPart) {
|
|
244
|
+
return formatCurrentAction(pendingPart);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (activityState.isReasoning && activityState.reasoningPreview) {
|
|
248
|
+
return `Analyzing: ${activityState.reasoningPreview}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (activityState.patchFilesCount > 0) {
|
|
252
|
+
return `Writing ${activityState.patchFilesCount} file${activityState.patchFilesCount !== 1 ? "s" : ""}...`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (activityState.stepFinishReason === "tool-calls") {
|
|
256
|
+
return "Waiting for tool results";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { AgentPhase, MessageMeta, SessionStatus } from "../../shared/types";
|
|
2
|
+
|
|
3
|
+
export type WaitingReason = "user" | "children";
|
|
4
|
+
|
|
5
|
+
export interface SessionStatusInfo {
|
|
6
|
+
status: SessionStatus;
|
|
7
|
+
waitingReason?: WaitingReason;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const WORKING_THRESHOLD = 30 * 1000;
|
|
11
|
+
const COMPLETED_THRESHOLD = 5 * 60 * 1000;
|
|
12
|
+
const GRACE_PERIOD = 5 * 1000;
|
|
13
|
+
|
|
14
|
+
export function isAssistantFinished(messages: MessageMeta[]): boolean {
|
|
15
|
+
if (messages.length === 0) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const lastMessage = messages.reduce((latest, current) =>
|
|
20
|
+
current.createdAt.getTime() > latest.createdAt.getTime() ? current : latest
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
return lastMessage.role === "assistant" && lastMessage.finish === "stop";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function detectAgentPhases(messages: MessageMeta[]): AgentPhase[] {
|
|
27
|
+
const sorted = messages
|
|
28
|
+
.filter(m => m.role === "assistant" && m.agent)
|
|
29
|
+
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
30
|
+
|
|
31
|
+
if (sorted.length === 0) return [];
|
|
32
|
+
|
|
33
|
+
const phases: AgentPhase[] = [];
|
|
34
|
+
let currentPhase: AgentPhase | null = null;
|
|
35
|
+
|
|
36
|
+
for (const msg of sorted) {
|
|
37
|
+
if (!currentPhase || currentPhase.agent !== msg.agent) {
|
|
38
|
+
if (currentPhase) phases.push(currentPhase);
|
|
39
|
+
currentPhase = {
|
|
40
|
+
agent: msg.agent!,
|
|
41
|
+
startTime: msg.createdAt,
|
|
42
|
+
endTime: msg.createdAt,
|
|
43
|
+
tokens: msg.tokens || 0,
|
|
44
|
+
messageCount: 1,
|
|
45
|
+
};
|
|
46
|
+
} else {
|
|
47
|
+
currentPhase.endTime = msg.createdAt;
|
|
48
|
+
currentPhase.tokens += msg.tokens || 0;
|
|
49
|
+
currentPhase.messageCount++;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (currentPhase) phases.push(currentPhase);
|
|
53
|
+
|
|
54
|
+
return phases;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getSessionStatusInfo(
|
|
58
|
+
messages: MessageMeta[],
|
|
59
|
+
hasPendingToolCall: boolean = false,
|
|
60
|
+
lastToolCompletedAt?: Date,
|
|
61
|
+
workingChildCount?: number,
|
|
62
|
+
lastAssistantFinished?: boolean,
|
|
63
|
+
isSubagent: boolean = false
|
|
64
|
+
): SessionStatusInfo {
|
|
65
|
+
if (hasPendingToolCall) {
|
|
66
|
+
return { status: "working" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (workingChildCount && workingChildCount > 0) {
|
|
70
|
+
return { status: "waiting", waitingReason: "children" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let timeSinceLastMessage = Infinity;
|
|
74
|
+
if (messages && messages.length > 0) {
|
|
75
|
+
const lastMessage = messages.reduce((latest, msg) =>
|
|
76
|
+
msg.createdAt.getTime() > latest.createdAt.getTime() ? msg : latest
|
|
77
|
+
);
|
|
78
|
+
timeSinceLastMessage = Date.now() - lastMessage.createdAt.getTime();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (lastAssistantFinished && timeSinceLastMessage < COMPLETED_THRESHOLD) {
|
|
82
|
+
if (isSubagent) {
|
|
83
|
+
return { status: "completed" };
|
|
84
|
+
}
|
|
85
|
+
return { status: "waiting", waitingReason: "user" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (lastToolCompletedAt) {
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
const timeSinceToolCompleted = now - lastToolCompletedAt.getTime();
|
|
91
|
+
if (timeSinceToolCompleted < GRACE_PERIOD) {
|
|
92
|
+
return { status: "working" };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!messages || messages.length === 0) {
|
|
97
|
+
return { status: "completed" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (timeSinceLastMessage < WORKING_THRESHOLD) {
|
|
101
|
+
return { status: "working" };
|
|
102
|
+
} else if (timeSinceLastMessage < COMPLETED_THRESHOLD) {
|
|
103
|
+
return { status: "idle" };
|
|
104
|
+
} else {
|
|
105
|
+
return { status: "completed" };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
import type { Hono } from "hono";
|
|
2
|
-
import {
|
|
2
|
+
import { queryPart } from "../storage/queries";
|
|
3
|
+
import { toPartMeta } from "../services/parsing";
|
|
3
4
|
import { partIdSchema, validateWithResponse } from "../validation";
|
|
4
5
|
|
|
5
6
|
export function registerPartRoutes(app: Hono) {
|
|
6
|
-
app.get("/api/parts/:id",
|
|
7
|
+
app.get("/api/parts/:id", (c) => {
|
|
7
8
|
const validation = validateWithResponse(partIdSchema, c.req.param("id"), c);
|
|
8
9
|
if (!validation.success) return validation.response;
|
|
9
10
|
const partID = validation.value;
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
if (!
|
|
11
|
+
|
|
12
|
+
const row = queryPart(partID);
|
|
13
|
+
|
|
14
|
+
if (!row) {
|
|
14
15
|
return c.json({ error: "PART_NOT_FOUND", message: `Part '${partID}' not found`, status: 404 }, 404);
|
|
15
16
|
}
|
|
16
|
-
|
|
17
|
+
|
|
18
|
+
const part = toPartMeta(row);
|
|
17
19
|
return c.json(part);
|
|
18
20
|
});
|
|
19
21
|
}
|