dsh-bots 0.0.1 → 0.2.10
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/LICENSE +21 -0
- package/README.md +8 -16
- package/cordis.patch.yml +5 -0
- package/lib/client.js +2199 -0
- package/lib/gateway.js +240 -0
- package/lib/index.js +508 -0
- package/lib/shared.js +37 -0
- package/lib/sse.js +249 -0
- package/lib/types/client.d.ts +35 -0
- package/lib/types/gateway.d.ts +32 -0
- package/lib/types/index.d.ts +210 -0
- package/lib/types/shared.d.ts +191 -0
- package/lib/types/sse.d.ts +79 -0
- package/lib/types/unread.d.ts +74 -0
- package/lib/types/version.d.ts +28 -0
- package/lib/types/workspace.d.ts +49 -0
- package/lib/unread.js +196 -0
- package/lib/version.js +109 -0
- package/lib/workspace.js +129 -0
- package/package.json +50 -14
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire types shared by the host half (gateway bridge) and the client half
|
|
3
|
+
* (web UI). Keep everything JSON-serializable: these shapes cross the
|
|
4
|
+
* connection RPC boundary verbatim.
|
|
5
|
+
* @module dsh-bots/shared
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Trimmed agent projection the UI renders.
|
|
9
|
+
*
|
|
10
|
+
* The projection is deliberately WIDER than the rows it draws: the sidebar
|
|
11
|
+
* needs unread counts, the hidden-from-sidebar flag, and the authoritative
|
|
12
|
+
* composing state (never a client-side guess) to reach native parity. Only
|
|
13
|
+
* genuinely unbounded fields are held back — `avatarDataUrl` is capped by
|
|
14
|
+
* {@link AVATAR_DATA_URL_MAX} so a list poll can never carry megabytes.
|
|
15
|
+
*/
|
|
16
|
+
export interface AgentInfo {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
title: string;
|
|
21
|
+
isGroup: boolean;
|
|
22
|
+
memberIds: string[];
|
|
23
|
+
isRunning: boolean;
|
|
24
|
+
isActive: boolean;
|
|
25
|
+
lastMessagePreview: string | null;
|
|
26
|
+
updatedAt: string | null;
|
|
27
|
+
/** Authoritative "assistant is producing a reply" flag from the gateway. */
|
|
28
|
+
isComposingMessage: boolean;
|
|
29
|
+
hasUnread: boolean;
|
|
30
|
+
unreadCount: number;
|
|
31
|
+
/** User hid this agent from the sidebar; the nav must not list it. */
|
|
32
|
+
isHiddenFromSidebar: boolean;
|
|
33
|
+
/** Non-null when the agent is blocked waiting on the user. */
|
|
34
|
+
awaitingUserResponse: string | null;
|
|
35
|
+
lastActivityAt: number | null;
|
|
36
|
+
/** Inline avatar image, or null when absent/oversized (see hasAvatar). */
|
|
37
|
+
avatarDataUrl: string | null;
|
|
38
|
+
avatarShape: string | null;
|
|
39
|
+
avatarColor: string | null;
|
|
40
|
+
/** True when the agent has an avatar, even if it was too large to inline. */
|
|
41
|
+
hasAvatar: boolean;
|
|
42
|
+
}
|
|
43
|
+
/** Largest avatar data URL inlined into a list response (bytes). */
|
|
44
|
+
export declare const AVATAR_DATA_URL_MAX: number;
|
|
45
|
+
/** Workspace row (from the host workspaceRegistry). */
|
|
46
|
+
export interface WorkspaceInfo {
|
|
47
|
+
id: string;
|
|
48
|
+
title: string;
|
|
49
|
+
path: string | null;
|
|
50
|
+
}
|
|
51
|
+
/** Recent dsh session row (from the host sessionQuery + title snapshots). */
|
|
52
|
+
export interface SessionInfo {
|
|
53
|
+
id: string;
|
|
54
|
+
title: string;
|
|
55
|
+
live: boolean;
|
|
56
|
+
}
|
|
57
|
+
/** Gateway health/discovery result. */
|
|
58
|
+
export interface GatewayInfo {
|
|
59
|
+
ok: boolean;
|
|
60
|
+
baseUrl?: string;
|
|
61
|
+
port?: number;
|
|
62
|
+
pid?: number | null;
|
|
63
|
+
hasToken?: boolean;
|
|
64
|
+
reason?: string;
|
|
65
|
+
/** Effective sdk-bots data directory (from plugin config, not a constant). */
|
|
66
|
+
dataDir?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Box workspace root the engine's exec-daemon guards paths against
|
|
69
|
+
* (plugin-computed default `<dataDir>/box-workspace`; the engine's
|
|
70
|
+
* `/health` value wins once the engine exposes it). Per-agent working
|
|
71
|
+
* directories live at `<root>/agents/<agentId>`.
|
|
72
|
+
*/
|
|
73
|
+
workspaceRoot?: string;
|
|
74
|
+
health?: {
|
|
75
|
+
pid: number | null;
|
|
76
|
+
isBusy: boolean;
|
|
77
|
+
activeAgentId: string | null;
|
|
78
|
+
startedAt: string | null;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* One transcript entry, normalized to a small display union.
|
|
83
|
+
*
|
|
84
|
+
* sdk-bots emits many wire kinds (`message`, `send-message`, `tool-call`,
|
|
85
|
+
* `tool-result`, `thinking`, …). The host collapses them into `display`, so
|
|
86
|
+
* the client renders on a closed set instead of sniffing raw shapes — the
|
|
87
|
+
* previous UI read `entry.content` unconditionally and drew blanks for every
|
|
88
|
+
* kind that keeps its text somewhere else.
|
|
89
|
+
*/
|
|
90
|
+
export type TranscriptDisplay = 'user' | 'assistant' | 'tool' | 'thinking' | 'event';
|
|
91
|
+
export interface TranscriptEntry {
|
|
92
|
+
id: string;
|
|
93
|
+
/** Raw sdk-bots wire kind, kept for diagnostics and future kinds. */
|
|
94
|
+
kind: string;
|
|
95
|
+
/** Closed display union the renderer switches on. */
|
|
96
|
+
display: TranscriptDisplay;
|
|
97
|
+
timestampMs: number | null;
|
|
98
|
+
role: string | null;
|
|
99
|
+
content: string;
|
|
100
|
+
authorId: string | null;
|
|
101
|
+
authorName: string | null;
|
|
102
|
+
/** Streaming tail: the renderer shows a caret and suppresses the timestamp. */
|
|
103
|
+
isStreaming: boolean;
|
|
104
|
+
/** Tool name for `display: 'tool'` entries. */
|
|
105
|
+
toolName: string | null;
|
|
106
|
+
/** `running` | `ok` | `error` for `display: 'tool'` entries. */
|
|
107
|
+
toolStatus: 'running' | 'ok' | 'error' | null;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Every prop the dsh shell's renderer puts on a slot component before the
|
|
111
|
+
* entry's own `inject` result, and how our `sidebar.workspaces` shadow
|
|
112
|
+
* reproduces it when it re-renders the shipped entry underneath.
|
|
113
|
+
*
|
|
114
|
+
* Taking over a `single` slot means taking over this assembly. The list is the
|
|
115
|
+
* contract: `tests/delegation.spec.ts` reads the installed renderer and fails
|
|
116
|
+
* if it grows a key that is not accounted for here, so a dsh upgrade surfaces
|
|
117
|
+
* as a red test rather than as another silently blank region.
|
|
118
|
+
*/
|
|
119
|
+
export declare const DELEGATED_KIT_KEYS: Record<string, 'synthesized' | 'session-only' | 'guarded'>;
|
|
120
|
+
/** Plugin configuration supplied through cordis.yml. */
|
|
121
|
+
export interface Config {
|
|
122
|
+
/** sdk-bots data directory holding gateway.json. */
|
|
123
|
+
dataDir: string;
|
|
124
|
+
}
|
|
125
|
+
/** `bots/<method>` RPC request/response envelope used by the web client. */
|
|
126
|
+
export type RpcEnvelope<T> = {
|
|
127
|
+
ok: true;
|
|
128
|
+
value: T;
|
|
129
|
+
} | {
|
|
130
|
+
ok: false;
|
|
131
|
+
error: {
|
|
132
|
+
message: string;
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
/** One normalized SSE channel event with a local monotonic seq. */
|
|
136
|
+
export interface SseEvent {
|
|
137
|
+
seq: number;
|
|
138
|
+
channel: string;
|
|
139
|
+
data: unknown;
|
|
140
|
+
}
|
|
141
|
+
/** Snapshot of the host SSE ring lifecycle for the UI status line. */
|
|
142
|
+
export interface SseState {
|
|
143
|
+
running: boolean;
|
|
144
|
+
ok: boolean;
|
|
145
|
+
lastError: string | null;
|
|
146
|
+
connectedAt: string | null;
|
|
147
|
+
droppedAt: string | null;
|
|
148
|
+
buffered: number;
|
|
149
|
+
total: number;
|
|
150
|
+
}
|
|
151
|
+
/** Reply to `bots.eventsSince(seq)`: replay from the ring plus live status. */
|
|
152
|
+
export interface EventsSinceResult {
|
|
153
|
+
events: SseEvent[];
|
|
154
|
+
/** Next seq the caller should request next poll. */
|
|
155
|
+
nextSeq: number;
|
|
156
|
+
state: SseState;
|
|
157
|
+
/**
|
|
158
|
+
* Unread counts per agent id, computed on the host from the same ring
|
|
159
|
+
* (plugin-owned model — the gateway's own unreadCount is desktop-app
|
|
160
|
+
* semantics and never accumulates headless). Only non-zero entries appear.
|
|
161
|
+
*/
|
|
162
|
+
unread?: Record<string, number>;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* One installed MCP server row (gateway `listMcpServers` projection).
|
|
166
|
+
* `status` is the runtime state reported by the engine — `connected`,
|
|
167
|
+
* `needsAuth`, `error`, … — the settings page maps it onto StateDot states.
|
|
168
|
+
*/
|
|
169
|
+
export interface McpServerInfo {
|
|
170
|
+
id: string;
|
|
171
|
+
serverIdentifier: string;
|
|
172
|
+
name: string;
|
|
173
|
+
status: string;
|
|
174
|
+
accountKey: string;
|
|
175
|
+
transport: string;
|
|
176
|
+
toolCount: number;
|
|
177
|
+
disabledToolCount?: number;
|
|
178
|
+
statusDetail?: string;
|
|
179
|
+
customInstructions?: string;
|
|
180
|
+
[key: string]: unknown;
|
|
181
|
+
}
|
|
182
|
+
/** One routed MCP tool row (gateway `listRoutedMcpTools` projection). */
|
|
183
|
+
export interface McpToolInfo {
|
|
184
|
+
/** Dynamic registration name the model addresses. */
|
|
185
|
+
name: string;
|
|
186
|
+
providerIdentifier: string;
|
|
187
|
+
/** Underlying tool name on the server. */
|
|
188
|
+
toolName: string;
|
|
189
|
+
description?: string;
|
|
190
|
+
inputSchema?: unknown;
|
|
191
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE plumbing for the host half — pure ring + parser (unit-tested) and a
|
|
3
|
+
* native `fetch` listener that keeps the ring fed from the sdk-bots gateway
|
|
4
|
+
* `/events` endpoint. The web client has no network, so this is the only live
|
|
5
|
+
* event path; it replays through `bots.eventsSince(seq)`.
|
|
6
|
+
*
|
|
7
|
+
* The wire uses `Accept-Encoding: identity` to sidestep gzip/deflate handling
|
|
8
|
+
* in the host fetch layer (the gateway also supports `?token=` for the old
|
|
9
|
+
* browser path, but we always send the `authorization` header).
|
|
10
|
+
* @module dsh-bots/sse
|
|
11
|
+
*/
|
|
12
|
+
import type { EventsSinceResult, SseEvent, SseState } from './shared.js';
|
|
13
|
+
export type { EventsSinceResult, SseEvent, SseState };
|
|
14
|
+
/** Incremental SSE wire parser: feed string chunks, receive frames. */
|
|
15
|
+
export declare class SseParser {
|
|
16
|
+
private readonly onEvent;
|
|
17
|
+
private buffer;
|
|
18
|
+
private event;
|
|
19
|
+
private dataLines;
|
|
20
|
+
constructor(onEvent: (event: string, data: string) => void);
|
|
21
|
+
push(chunk: string): void;
|
|
22
|
+
private feedLine;
|
|
23
|
+
}
|
|
24
|
+
/** Bounded, monotonic ring of normalized events with O(log n) `eventsSince`. */
|
|
25
|
+
export declare class SseRingBuffer {
|
|
26
|
+
/** Optional side-channel observer (unread tracking); must never throw. */
|
|
27
|
+
private readonly onPush?;
|
|
28
|
+
private readonly capacity;
|
|
29
|
+
private entries;
|
|
30
|
+
private nextSeq;
|
|
31
|
+
constructor(capacity?: number,
|
|
32
|
+
/** Optional side-channel observer (unread tracking); must never throw. */
|
|
33
|
+
onPush?: ((channel: string, data: unknown) => void) | undefined);
|
|
34
|
+
/** Append one event; returns its assigned seq. */
|
|
35
|
+
push(channel: string, data: unknown): number;
|
|
36
|
+
/** Replay events strictly after `seq`, or everything when the seq fell off. */
|
|
37
|
+
eventsSince(seq: number): SseEvent[];
|
|
38
|
+
get size(): number;
|
|
39
|
+
get total(): number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Long-lived `/events` listener. Re-resolves discovery on every connect so a
|
|
43
|
+
* restarted sdk-bots host is picked up automatically; reconnects are silent
|
|
44
|
+
* and bounded. Deliberately never throws — errors surface via `state()`.
|
|
45
|
+
*/
|
|
46
|
+
export declare class GatewaySseClient {
|
|
47
|
+
private readonly ring;
|
|
48
|
+
private readonly resolveBase;
|
|
49
|
+
private readonly reconnectBaseMs;
|
|
50
|
+
private readonly channels?;
|
|
51
|
+
private readonly onConnected?;
|
|
52
|
+
private ac;
|
|
53
|
+
private timer;
|
|
54
|
+
private stopped;
|
|
55
|
+
private ok;
|
|
56
|
+
private lastError;
|
|
57
|
+
private connectedAt;
|
|
58
|
+
private droppedAt;
|
|
59
|
+
private attempt;
|
|
60
|
+
constructor(opts: {
|
|
61
|
+
ring?: SseRingBuffer;
|
|
62
|
+
resolveBase: () => {
|
|
63
|
+
url: string;
|
|
64
|
+
token: string | null;
|
|
65
|
+
} | null;
|
|
66
|
+
reconnectBaseMs?: number;
|
|
67
|
+
/** Optional `channels` subscription subset, e.g. `transcript,agents`. */
|
|
68
|
+
channels?: string[];
|
|
69
|
+
/** Fired after every successful (re)connect — unread rebase hook. */
|
|
70
|
+
onConnected?: () => void;
|
|
71
|
+
});
|
|
72
|
+
get buffer(): SseRingBuffer;
|
|
73
|
+
start(): void;
|
|
74
|
+
stop(): void;
|
|
75
|
+
state(): SseState;
|
|
76
|
+
eventsSince(seq: number): EventsSinceResult;
|
|
77
|
+
private connect;
|
|
78
|
+
private schedule;
|
|
79
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent unread tracking for the sidebar badge.
|
|
3
|
+
*
|
|
4
|
+
* Why not the gateway's `unreadCount`? The sdk-bots host models unread state
|
|
5
|
+
* for the Grok desktop app: arrivals only count on the single `activeSession`,
|
|
6
|
+
* a focused window marks everything read instantly, and any transcript READ
|
|
7
|
+
* (`openAgentBounded` → `markAgentViewed`) clears the marker — which our own
|
|
8
|
+
* tail polling trips constantly. On a headless local gateway that model never
|
|
9
|
+
* accumulates (every agent observed at `unreadCount: 0` despite activity).
|
|
10
|
+
*
|
|
11
|
+
* So the plugin owns the model instead, at the one layer that is always
|
|
12
|
+
* watching: the host half's SSE ring. Rules:
|
|
13
|
+
*
|
|
14
|
+
* - A "message" is a human-visible transcript kind (user prompt, bot reply,
|
|
15
|
+
* attachment) — never tool calls, thinking, or system events.
|
|
16
|
+
* - Each agent has a persisted marker `lastReadAtMs` ("读到哪了") in
|
|
17
|
+
* `<dataDir>/dsh-bots-unread.json`; opening the conversation advances it.
|
|
18
|
+
* - Live arrivals with `timestampMs > marker` bump the count; anything at or
|
|
19
|
+
* before the marker is already-read history and is ignored (this is what
|
|
20
|
+
* makes snapshot replays on SSE reconnect idempotent).
|
|
21
|
+
* - After a restart the in-memory counts are gone but the markers survive,
|
|
22
|
+
* so a rebase from transcript tails reconstructs the counts exactly.
|
|
23
|
+
* - First run (no marker file yet) treats all existing history as read —
|
|
24
|
+
* an upgrade must not light up 30 badges of backlog.
|
|
25
|
+
* @module dsh-bots/unread
|
|
26
|
+
*/
|
|
27
|
+
/** True when a transcript entry kind should raise the unread badge. */
|
|
28
|
+
export declare function isMessageEntryKind(kind: unknown): boolean;
|
|
29
|
+
/** Seconds/milliseconds/ISO → epoch ms; null when unreadable. */
|
|
30
|
+
export declare function entryEpochMs(raw: unknown): number | null;
|
|
31
|
+
/** Minimal shape the store needs from a wire transcript entry. */
|
|
32
|
+
export interface UnreadEntry {
|
|
33
|
+
kind?: unknown;
|
|
34
|
+
timestampMs?: unknown;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Unread counts + persisted read markers, one instance per plugin host.
|
|
38
|
+
* All mutations are crash-safe: markers flush to disk synchronously (a few
|
|
39
|
+
* hundred bytes), counts are reconstructible so they stay in memory.
|
|
40
|
+
*/
|
|
41
|
+
export declare class UnreadStore {
|
|
42
|
+
private readonly filePath;
|
|
43
|
+
private readonly now;
|
|
44
|
+
private markers;
|
|
45
|
+
private countBy;
|
|
46
|
+
private loaded;
|
|
47
|
+
/** True when no marker file existed at first load (seed-legacy mode). */
|
|
48
|
+
private freshInstall;
|
|
49
|
+
constructor(filePath: string, now?: () => number);
|
|
50
|
+
private ensureLoaded;
|
|
51
|
+
private persist;
|
|
52
|
+
/** Snapshot of all live counts, keyed by agent id. */
|
|
53
|
+
counts(): Record<string, number>;
|
|
54
|
+
/** Current unread count for one agent (0 when none). */
|
|
55
|
+
unreadFor(agentId: string): number;
|
|
56
|
+
/**
|
|
57
|
+
* One live transcript arrival. Entries at or before the read marker are
|
|
58
|
+
* already-read history (snapshot replays) and are ignored.
|
|
59
|
+
*/
|
|
60
|
+
bump(agentId: string, entry: UnreadEntry | null | undefined): void;
|
|
61
|
+
/**
|
|
62
|
+
* The user read the conversation: zero the count and advance the marker
|
|
63
|
+
* ("上次读到哪" = now, or the explicit position when given).
|
|
64
|
+
*/
|
|
65
|
+
markRead(agentId: string, atMs?: number): void;
|
|
66
|
+
/**
|
|
67
|
+
* Recompute one agent's count from a transcript tail (reconnect/boot heal).
|
|
68
|
+
* An agent never seen before on a fresh install is seeded as read-up-to-now;
|
|
69
|
+
* an agent without a marker on a normal boot is genuinely never-read.
|
|
70
|
+
*/
|
|
71
|
+
rebase(agentId: string, entries: ReadonlyArray<UnreadEntry | null | undefined>): void;
|
|
72
|
+
/** Drop state for agents that no longer exist; returns the removed ids. */
|
|
73
|
+
prune(knownIds: ReadonlySet<string>): string[];
|
|
74
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal semver caret-range matching for the prerelease patterns this plugin
|
|
3
|
+
* actually uses (e.g. `^4.0.1`, `^0.1.1-rc.2`).
|
|
4
|
+
*
|
|
5
|
+
* Ported from the community dsh-plugin-template (MIT) and kept
|
|
6
|
+
* dependency-free: it implements the subset of node-semver semantics needed to
|
|
7
|
+
* turn a silent peer mismatch into a loud, actionable error.
|
|
8
|
+
* See tests/version.spec.ts for the behavior matrix.
|
|
9
|
+
* @module dsh-plugin-bots/version
|
|
10
|
+
*/
|
|
11
|
+
export interface ParsedVersion {
|
|
12
|
+
major: number;
|
|
13
|
+
minor: number;
|
|
14
|
+
patch: number;
|
|
15
|
+
/** Prerelease identifiers (e.g. ["rc", "2"]), or null for a stable version. */
|
|
16
|
+
prerelease: string[] | null;
|
|
17
|
+
}
|
|
18
|
+
/** Parse `X.Y.Z` or `X.Y.Z-pre` into a comparable structure, or null. */
|
|
19
|
+
export declare function parseVersion(input: string): ParsedVersion | null;
|
|
20
|
+
/** Compare two prerelease identifier lists; a stable version (null) is higher. */
|
|
21
|
+
export declare function comparePrerelease(a: string[] | null, b: string[] | null): number;
|
|
22
|
+
/** Upper exclusive bound of a caret range: ^0.1.0 → 0.2.0, ^1.2.3 → 2.0.0. */
|
|
23
|
+
export declare function caretUpperBound(version: ParsedVersion): [number, number, number];
|
|
24
|
+
/**
|
|
25
|
+
* Whether `version` satisfies the caret range `^X.Y.Z` or `^X.Y.Z-pre`.
|
|
26
|
+
* Mirrors node-semver for the subset this plugin declares.
|
|
27
|
+
*/
|
|
28
|
+
export declare function satisfiesCaret(version: string, range: string): boolean;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent workspace jail configuration bridge (plugin side).
|
|
3
|
+
*
|
|
4
|
+
* The ENGINE owns the isolation mechanism (`sdk-bots src/host/runner/
|
|
5
|
+
* agent-workspace-jail.ts`): when an agent's settings.json declares
|
|
6
|
+
* `workspaceRoot`, every shell that agent runs is wrapped in a generated
|
|
7
|
+
* macOS Seatbelt profile that denies file WRITES outside the agent's own
|
|
8
|
+
* workspace directory (+ allowPaths + OS temp). Reads stay unrestricted so
|
|
9
|
+
* shared blackboards remain readable. The jail resolves lazily per turn —
|
|
10
|
+
* editing settings.json takes effect on the agent's next turn, no restart.
|
|
11
|
+
*
|
|
12
|
+
* This module only mirrors the engine's config contract so the plugin can
|
|
13
|
+
* read and write it safely:
|
|
14
|
+
* `<dataDir>/agents/<agentId>/settings.json` →
|
|
15
|
+
* { workspaceRoot?: "/workspace/<slug>", workspaceAllowPaths?: string[] }
|
|
16
|
+
*
|
|
17
|
+
* Validation mirrors the engine exactly (SLUG_PATTERN, agentId pattern,
|
|
18
|
+
* virtual-prefix form) because the engine FAILS CLOSED on malformed config —
|
|
19
|
+
* a bad write would break the agent's turns, not silently run unjailed.
|
|
20
|
+
* @module dsh-bots/workspace
|
|
21
|
+
*/
|
|
22
|
+
/** Bot-facing virtual root prefix; the daemon maps it under the box root. */
|
|
23
|
+
export declare const WORKSPACE_VIRTUAL_PREFIX = "/workspace/";
|
|
24
|
+
/** One agent's jail config as stored on disk. */
|
|
25
|
+
export interface AgentWorkspaceConfig {
|
|
26
|
+
agentId: string;
|
|
27
|
+
/** `/workspace/<slug>` when jailed, null otherwise. */
|
|
28
|
+
workspaceRoot: string | null;
|
|
29
|
+
/** Extra host paths the jailed agent may write. */
|
|
30
|
+
allowPaths: string[];
|
|
31
|
+
}
|
|
32
|
+
/** Validate the `/workspace/<slug>` virtual form exactly like the engine. */
|
|
33
|
+
export declare function validateVirtualRoot(requested: string): string;
|
|
34
|
+
/** Read one agent's jail config; null when the agent dir does not exist. */
|
|
35
|
+
export declare function readAgentWorkspace(dataDir: string, agentId: string): AgentWorkspaceConfig | null;
|
|
36
|
+
/** Scan every agent directory for its jail config (missing settings → unjailed). */
|
|
37
|
+
export declare function listAgentWorkspaces(dataDir: string): AgentWorkspaceConfig[];
|
|
38
|
+
export interface SetAgentWorkspaceRequest {
|
|
39
|
+
/** `/workspace/<slug>` to jail; null/undefined removes the jail. */
|
|
40
|
+
workspaceRoot?: string | null;
|
|
41
|
+
/** Extra writable host paths; replaces the previous list. */
|
|
42
|
+
allowPaths?: string[];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Write one agent's jail keys into its settings.json, preserving every other
|
|
46
|
+
* field. Creates the agent dir when missing (a freshly created bot may not
|
|
47
|
+
* have its settings.json yet).
|
|
48
|
+
*/
|
|
49
|
+
export declare function setAgentWorkspace(dataDir: string, agentId: string, request: SetAgentWorkspaceRequest): AgentWorkspaceConfig;
|
package/lib/unread.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent unread tracking for the sidebar badge.
|
|
3
|
+
*
|
|
4
|
+
* Why not the gateway's `unreadCount`? The sdk-bots host models unread state
|
|
5
|
+
* for the Grok desktop app: arrivals only count on the single `activeSession`,
|
|
6
|
+
* a focused window marks everything read instantly, and any transcript READ
|
|
7
|
+
* (`openAgentBounded` → `markAgentViewed`) clears the marker — which our own
|
|
8
|
+
* tail polling trips constantly. On a headless local gateway that model never
|
|
9
|
+
* accumulates (every agent observed at `unreadCount: 0` despite activity).
|
|
10
|
+
*
|
|
11
|
+
* So the plugin owns the model instead, at the one layer that is always
|
|
12
|
+
* watching: the host half's SSE ring. Rules:
|
|
13
|
+
*
|
|
14
|
+
* - A "message" is a human-visible transcript kind (user prompt, bot reply,
|
|
15
|
+
* attachment) — never tool calls, thinking, or system events.
|
|
16
|
+
* - Each agent has a persisted marker `lastReadAtMs` ("读到哪了") in
|
|
17
|
+
* `<dataDir>/dsh-bots-unread.json`; opening the conversation advances it.
|
|
18
|
+
* - Live arrivals with `timestampMs > marker` bump the count; anything at or
|
|
19
|
+
* before the marker is already-read history and is ignored (this is what
|
|
20
|
+
* makes snapshot replays on SSE reconnect idempotent).
|
|
21
|
+
* - After a restart the in-memory counts are gone but the markers survive,
|
|
22
|
+
* so a rebase from transcript tails reconstructs the counts exactly.
|
|
23
|
+
* - First run (no marker file yet) treats all existing history as read —
|
|
24
|
+
* an upgrade must not light up 30 badges of backlog.
|
|
25
|
+
* @module dsh-bots/unread
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { dirname } from 'node:path';
|
|
29
|
+
/** Wire `kind`s that represent a message a human would want to see. */
|
|
30
|
+
const MESSAGE_KINDS = new Set([
|
|
31
|
+
'message',
|
|
32
|
+
'user-message',
|
|
33
|
+
'send-message',
|
|
34
|
+
'agent-message',
|
|
35
|
+
'assistant-text',
|
|
36
|
+
'user-attachment',
|
|
37
|
+
]);
|
|
38
|
+
/** True when a transcript entry kind should raise the unread badge. */
|
|
39
|
+
export function isMessageEntryKind(kind) {
|
|
40
|
+
return typeof kind === 'string' && MESSAGE_KINDS.has(kind);
|
|
41
|
+
}
|
|
42
|
+
/** Seconds/milliseconds/ISO → epoch ms; null when unreadable. */
|
|
43
|
+
export function entryEpochMs(raw) {
|
|
44
|
+
if (typeof raw === 'number') {
|
|
45
|
+
if (!Number.isFinite(raw) || raw <= 0)
|
|
46
|
+
return null;
|
|
47
|
+
return Math.round(raw < 1e11 ? raw * 1000 : raw);
|
|
48
|
+
}
|
|
49
|
+
if (typeof raw !== 'string' || raw === '')
|
|
50
|
+
return null;
|
|
51
|
+
if (/^\d+$/.test(raw))
|
|
52
|
+
return entryEpochMs(Number(raw));
|
|
53
|
+
const parsed = Date.parse(raw);
|
|
54
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Unread counts + persisted read markers, one instance per plugin host.
|
|
58
|
+
* All mutations are crash-safe: markers flush to disk synchronously (a few
|
|
59
|
+
* hundred bytes), counts are reconstructible so they stay in memory.
|
|
60
|
+
*/
|
|
61
|
+
export class UnreadStore {
|
|
62
|
+
filePath;
|
|
63
|
+
now;
|
|
64
|
+
markers = new Map();
|
|
65
|
+
countBy = new Map();
|
|
66
|
+
loaded = false;
|
|
67
|
+
/** True when no marker file existed at first load (seed-legacy mode). */
|
|
68
|
+
freshInstall = false;
|
|
69
|
+
constructor(filePath, now = Date.now) {
|
|
70
|
+
this.filePath = filePath;
|
|
71
|
+
this.now = now;
|
|
72
|
+
}
|
|
73
|
+
ensureLoaded() {
|
|
74
|
+
if (this.loaded)
|
|
75
|
+
return;
|
|
76
|
+
this.loaded = true;
|
|
77
|
+
try {
|
|
78
|
+
if (existsSync(this.filePath)) {
|
|
79
|
+
const raw = JSON.parse(readFileSync(this.filePath, 'utf8'));
|
|
80
|
+
if (raw !== null && typeof raw === 'object' && raw.version === 1 && raw.markers !== null && typeof raw.markers === 'object') {
|
|
81
|
+
for (const [id, at] of Object.entries(raw.markers)) {
|
|
82
|
+
if (typeof at === 'number' && Number.isFinite(at) && at > 0)
|
|
83
|
+
this.markers.set(id, at);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
// First run after the feature (or after manually clearing state):
|
|
89
|
+
// existing history counts as read, only new traffic raises badges.
|
|
90
|
+
this.freshInstall = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// A corrupt file must not brick the badge; treat as fresh.
|
|
95
|
+
this.freshInstall = true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
persist() {
|
|
99
|
+
try {
|
|
100
|
+
const markers = {};
|
|
101
|
+
for (const [id, at] of this.markers)
|
|
102
|
+
markers[id] = at;
|
|
103
|
+
const body = { version: 1, markers };
|
|
104
|
+
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
105
|
+
writeFileSync(this.filePath, JSON.stringify(body));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Best effort: counts still work in memory; markers rebuild lazily.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Snapshot of all live counts, keyed by agent id. */
|
|
112
|
+
counts() {
|
|
113
|
+
this.ensureLoaded();
|
|
114
|
+
const out = {};
|
|
115
|
+
for (const [id, n] of this.countBy) {
|
|
116
|
+
if (n > 0)
|
|
117
|
+
out[id] = n;
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
/** Current unread count for one agent (0 when none). */
|
|
122
|
+
unreadFor(agentId) {
|
|
123
|
+
this.ensureLoaded();
|
|
124
|
+
return this.countBy.get(agentId) ?? 0;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* One live transcript arrival. Entries at or before the read marker are
|
|
128
|
+
* already-read history (snapshot replays) and are ignored.
|
|
129
|
+
*/
|
|
130
|
+
bump(agentId, entry) {
|
|
131
|
+
if (typeof agentId !== 'string' || agentId === '' || !isMessageEntryKind(entry?.kind))
|
|
132
|
+
return;
|
|
133
|
+
this.ensureLoaded();
|
|
134
|
+
const ts = entryEpochMs(entry?.timestampMs) ?? this.now();
|
|
135
|
+
if (ts <= (this.markers.get(agentId) ?? 0))
|
|
136
|
+
return;
|
|
137
|
+
this.countBy.set(agentId, (this.countBy.get(agentId) ?? 0) + 1);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The user read the conversation: zero the count and advance the marker
|
|
141
|
+
* ("上次读到哪" = now, or the explicit position when given).
|
|
142
|
+
*/
|
|
143
|
+
markRead(agentId, atMs) {
|
|
144
|
+
if (typeof agentId !== 'string' || agentId === '')
|
|
145
|
+
return;
|
|
146
|
+
this.ensureLoaded();
|
|
147
|
+
const at = Math.max(atMs ?? this.now(), this.markers.get(agentId) ?? 0);
|
|
148
|
+
this.markers.set(agentId, at);
|
|
149
|
+
this.countBy.set(agentId, 0);
|
|
150
|
+
this.persist();
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Recompute one agent's count from a transcript tail (reconnect/boot heal).
|
|
154
|
+
* An agent never seen before on a fresh install is seeded as read-up-to-now;
|
|
155
|
+
* an agent without a marker on a normal boot is genuinely never-read.
|
|
156
|
+
*/
|
|
157
|
+
rebase(agentId, entries) {
|
|
158
|
+
if (typeof agentId !== 'string' || agentId === '')
|
|
159
|
+
return;
|
|
160
|
+
this.ensureLoaded();
|
|
161
|
+
if (this.freshInstall && !this.markers.has(agentId)) {
|
|
162
|
+
this.markers.set(agentId, this.now());
|
|
163
|
+
this.countBy.set(agentId, 0);
|
|
164
|
+
this.persist();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const marker = this.markers.get(agentId) ?? 0;
|
|
168
|
+
let n = 0;
|
|
169
|
+
for (const entry of entries) {
|
|
170
|
+
if (!isMessageEntryKind(entry?.kind))
|
|
171
|
+
continue;
|
|
172
|
+
const ts = entryEpochMs(entry?.timestampMs);
|
|
173
|
+
if (ts !== null && ts > marker)
|
|
174
|
+
n += 1;
|
|
175
|
+
}
|
|
176
|
+
this.countBy.set(agentId, n);
|
|
177
|
+
}
|
|
178
|
+
/** Drop state for agents that no longer exist; returns the removed ids. */
|
|
179
|
+
prune(knownIds) {
|
|
180
|
+
this.ensureLoaded();
|
|
181
|
+
const removed = [];
|
|
182
|
+
for (const id of [...this.markers.keys()]) {
|
|
183
|
+
if (!knownIds.has(id)) {
|
|
184
|
+
this.markers.delete(id);
|
|
185
|
+
removed.push(id);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (const id of [...this.countBy.keys()]) {
|
|
189
|
+
if (!knownIds.has(id))
|
|
190
|
+
this.countBy.delete(id);
|
|
191
|
+
}
|
|
192
|
+
if (removed.length > 0)
|
|
193
|
+
this.persist();
|
|
194
|
+
return removed;
|
|
195
|
+
}
|
|
196
|
+
}
|