dsh-ntr 0.3.0 → 0.3.1
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/lib/bridge.js +7 -7
- package/lib/client/panel.js +33 -3
- package/lib/client/store.js +55 -3
- package/lib/client.js +129 -3
- package/lib/client.js.map +1 -1
- package/lib/types/bridge.d.ts +2 -2
- package/lib/types/client/store.d.ts +33 -0
- package/lib/types/protocol.d.ts +7 -0
- package/lib/types/update.d.ts +7 -4
- package/lib/update.js +25 -10
- package/package.json +1 -1
- package/src/bridge.ts +8 -8
- package/src/client/panel.tsx +73 -2
- package/src/client/store.ts +88 -6
- package/src/protocol.ts +8 -0
- package/src/update.ts +21 -8
package/lib/types/bridge.d.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { type InitializeResponse } from '@agentclientprotocol/sdk';
|
|
10
10
|
import { EventHub, PermissionLedger, Transcript } from './impl.ts';
|
|
11
11
|
import type { AcpToolCallSnapshot, PendingPermissionView, RosterPresetRow } from './impl.ts';
|
|
12
|
-
import type { AcpAgentCatalog, AcpAgentDef, AcpApprovalDeadlines, AcpConversationChoice, AcpPresetStatus, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult, AgentView, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, GenerateOptions, LlmAdapterLike, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, ApprovalDeadlineKey, ApprovalDeadlinePatch, PermissionTimeoutMode, PluginUpdateState, PreparedAdapterCall, SessionView, StreamChunk } from './protocol.ts';
|
|
12
|
+
import type { AcpAgentCatalog, AcpAgentDef, AcpApprovalDeadlines, AcpConversationChoice, AcpPresetStatus, AcpReleaseCause, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult, AgentView, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, GenerateOptions, LlmAdapterLike, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, ApprovalDeadlineKey, ApprovalDeadlinePatch, PermissionTimeoutMode, PluginUpdateState, PreparedAdapterCall, SessionView, StreamChunk } from './protocol.ts';
|
|
13
13
|
import { UpdateService } from './update.ts';
|
|
14
14
|
/**
|
|
15
15
|
* Version of this installed package, read from the shipped `package.json` —
|
|
@@ -612,7 +612,7 @@ export declare class AgentBridge {
|
|
|
612
612
|
resumeSession(session: LiveSession): Promise<LiveSession>;
|
|
613
613
|
private doResumeSession;
|
|
614
614
|
/** Release all live sessions (process died or went idle): keep them resumable, settle permissions, drop the connection. */
|
|
615
|
-
releaseAll(reason: string): void;
|
|
615
|
+
releaseAll(reason: string, cause: AcpReleaseCause): void;
|
|
616
616
|
/** Detach one released session record for a resume attempt. */
|
|
617
617
|
takeReleased(sessionId: string): LiveSession | undefined;
|
|
618
618
|
/** Drop a released session record for good (user closed it or the agent was stopped). */
|
|
@@ -141,6 +141,20 @@ export interface ChatTranscript {
|
|
|
141
141
|
/** Highest folded transcript seq (gap detection for the live stream). */
|
|
142
142
|
seq: number;
|
|
143
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* One line of the settings page's ACP runtime log. Agent-side notices and
|
|
146
|
+
* session-release reasons land here (bounded, append order oldest→newest);
|
|
147
|
+
* failures of the settings page's OWN actions stay in {@link UiState.notice}.
|
|
148
|
+
*/
|
|
149
|
+
export interface AcpLogEntry {
|
|
150
|
+
id: number;
|
|
151
|
+
at: number;
|
|
152
|
+
level: 'info' | 'error';
|
|
153
|
+
text: string;
|
|
154
|
+
agentId?: string;
|
|
155
|
+
}
|
|
156
|
+
/** Cap on retained runtime-log lines; older entries fall off the front. */
|
|
157
|
+
export declare const LOG_MAX_ENTRIES = 200;
|
|
144
158
|
export interface UiState {
|
|
145
159
|
open: boolean;
|
|
146
160
|
/**
|
|
@@ -162,7 +176,15 @@ export interface UiState {
|
|
|
162
176
|
blocks: Block[];
|
|
163
177
|
permissions: PendingPermission[];
|
|
164
178
|
busy: boolean;
|
|
179
|
+
/**
|
|
180
|
+
* The settings page's one-line banner for its OWN action failures. Agent-side
|
|
181
|
+
* runtime messages do not land here; they append to {@link logs} instead.
|
|
182
|
+
*/
|
|
165
183
|
notice?: string;
|
|
184
|
+
/** Agent-side runtime log, append order oldest→newest, capped at {@link LOG_MAX_ENTRIES}. */
|
|
185
|
+
logs: AcpLogEntry[];
|
|
186
|
+
/** Lines appended since the user last opened the log; reset by {@link AcpStore.markLogsRead}. */
|
|
187
|
+
logUnread: number;
|
|
166
188
|
/**
|
|
167
189
|
* The plugin's OWN default ACP agent (the panel's ★ gesture) — which agent a
|
|
168
190
|
* new ACP-mode conversation starts on. NOT the deployment default model: that
|
|
@@ -339,6 +361,8 @@ export declare class AcpStore {
|
|
|
339
361
|
private state;
|
|
340
362
|
private readonly listeners;
|
|
341
363
|
private source?;
|
|
364
|
+
/** Monotonic id source for {@link AcpLogEntry}s (never reset by `clearLogs`). */
|
|
365
|
+
private logSeq;
|
|
342
366
|
private readonly lastSeq;
|
|
343
367
|
/** Sessions with a finalized turn (dedups the cancel-fallback + late real turn/end pair). */
|
|
344
368
|
private readonly openTurns;
|
|
@@ -407,6 +431,11 @@ export declare class AcpStore {
|
|
|
407
431
|
subscribe(listener: Listener): () => void;
|
|
408
432
|
getSnapshot(): UiState;
|
|
409
433
|
private mutate;
|
|
434
|
+
/**
|
|
435
|
+
* Build the log patch for one line. Does NOT mutate: the caller merges it
|
|
436
|
+
* into the single `mutate` call it was already making for the same event.
|
|
437
|
+
*/
|
|
438
|
+
private logPatch;
|
|
410
439
|
/**
|
|
411
440
|
* Live `session/update` frames waiting to be folded. Agents stream at token
|
|
412
441
|
* granularity — hundreds of frames a second with usage ticks interleaved —
|
|
@@ -713,6 +742,10 @@ export declare class AcpStore {
|
|
|
713
742
|
*/
|
|
714
743
|
refreshLocale(): void;
|
|
715
744
|
clearNotice(): void;
|
|
745
|
+
/** Drop every retained runtime-log line (the unread counter restarts too). */
|
|
746
|
+
clearLogs(): void;
|
|
747
|
+
/** The user looked at the log: clear the unread badge. */
|
|
748
|
+
markLogsRead(): void;
|
|
716
749
|
private flashError;
|
|
717
750
|
private applyEvent;
|
|
718
751
|
}
|
package/lib/types/protocol.d.ts
CHANGED
|
@@ -321,6 +321,11 @@ export interface AgentView {
|
|
|
321
321
|
*/
|
|
322
322
|
idleTimeoutMs?: number;
|
|
323
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* Why the host released a session's agent process. The browser turns this into
|
|
326
|
+
* one runtime-log line per cause; `idle` stays silent (expected lifecycle).
|
|
327
|
+
*/
|
|
328
|
+
export type AcpReleaseCause = 'idle' | 'workspace' | 'resume-failed' | 'stalled' | 'died';
|
|
324
329
|
export type AcpEvent = {
|
|
325
330
|
type: 'agent/status';
|
|
326
331
|
agentId: string;
|
|
@@ -350,6 +355,8 @@ export type AcpEvent = {
|
|
|
350
355
|
sessionId: string;
|
|
351
356
|
session: SessionView;
|
|
352
357
|
reason: string;
|
|
358
|
+
/** Host's release-cause classification; absent on an older host half. */
|
|
359
|
+
cause?: AcpReleaseCause;
|
|
353
360
|
} | {
|
|
354
361
|
/**
|
|
355
362
|
* The session was resumed through `session/load` on a fresh agent
|
package/lib/types/update.d.ts
CHANGED
|
@@ -77,10 +77,13 @@ export type BundleEligibility = {
|
|
|
77
77
|
};
|
|
78
78
|
/**
|
|
79
79
|
* Whether a `listBundles().source` value names a registry-resolvable version
|
|
80
|
-
* spec — the only install shape this updater may replace. The
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
80
|
+
* spec — the only install shape this updater may replace. The harness records
|
|
81
|
+
* a registry dependency as `<package>@<spec>` (`dependencySpec` in the plugin
|
|
82
|
+
* manager), so the spec half after the last `@` is what gets judged. The test
|
|
83
|
+
* is positive on purpose: link/file/git/workspace/URL/tarball/relative forms
|
|
84
|
+
* all carry a `:` / `/` / `\` or start with `.`, so they fall out without an
|
|
85
|
+
* ever-growing blacklist (a new source scheme must not become silently
|
|
86
|
+
* updatable).
|
|
84
87
|
*/
|
|
85
88
|
export declare function isRegistrySpec(source: string): boolean;
|
|
86
89
|
/**
|
package/lib/update.js
CHANGED
|
@@ -46,12 +46,26 @@ function describeFieldError(error) {
|
|
|
46
46
|
.filter((part) => typeof part === 'string' && part !== '');
|
|
47
47
|
return parts.length > 0 ? parts.join(': ') : 'the host reported a bundle error';
|
|
48
48
|
}
|
|
49
|
+
/** The version-spec half of a registry source: a range, wildcard, or dist-tag. */
|
|
50
|
+
function isRegistryRange(value) {
|
|
51
|
+
if (value === '')
|
|
52
|
+
return false;
|
|
53
|
+
const lowered = value.toLowerCase();
|
|
54
|
+
if (lowered === '*' || lowered === 'x')
|
|
55
|
+
return true;
|
|
56
|
+
if (REGISTRY_TAGS.has(lowered))
|
|
57
|
+
return true;
|
|
58
|
+
return REGISTRY_VERSION_SPEC.test(value);
|
|
59
|
+
}
|
|
49
60
|
/**
|
|
50
61
|
* Whether a `listBundles().source` value names a registry-resolvable version
|
|
51
|
-
* spec — the only install shape this updater may replace. The
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
62
|
+
* spec — the only install shape this updater may replace. The harness records
|
|
63
|
+
* a registry dependency as `<package>@<spec>` (`dependencySpec` in the plugin
|
|
64
|
+
* manager), so the spec half after the last `@` is what gets judged. The test
|
|
65
|
+
* is positive on purpose: link/file/git/workspace/URL/tarball/relative forms
|
|
66
|
+
* all carry a `:` / `/` / `\` or start with `.`, so they fall out without an
|
|
67
|
+
* ever-growing blacklist (a new source scheme must not become silently
|
|
68
|
+
* updatable).
|
|
55
69
|
*/
|
|
56
70
|
export function isRegistrySpec(source) {
|
|
57
71
|
const value = typeof source === 'string' ? source.trim() : '';
|
|
@@ -61,12 +75,13 @@ export function isRegistrySpec(source) {
|
|
|
61
75
|
return false;
|
|
62
76
|
if (value.includes(':') || value.includes('/') || value.includes('\\'))
|
|
63
77
|
return false;
|
|
64
|
-
const
|
|
65
|
-
if (
|
|
66
|
-
return
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
78
|
+
const at = value.lastIndexOf('@');
|
|
79
|
+
if (at === -1)
|
|
80
|
+
return isRegistryRange(value);
|
|
81
|
+
const name = value.slice(0, at);
|
|
82
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(name))
|
|
83
|
+
return false;
|
|
84
|
+
return isRegistryRange(value.slice(at + 1));
|
|
70
85
|
}
|
|
71
86
|
/**
|
|
72
87
|
* Compare two semver strings (build metadata ignored, prerelease precedence per
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-ntr",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Interactive Agent Client Protocol (ACP) client for the DSH web UI: attach any ACP-capable agent and stream its session.",
|
|
5
5
|
"repository": "https://github.com/GDWhisper/dsh-ntr.git",
|
|
6
6
|
"type": "module",
|
package/src/bridge.ts
CHANGED
|
@@ -69,7 +69,7 @@ import type {
|
|
|
69
69
|
RosterPresetRow,
|
|
70
70
|
} from './impl.ts'
|
|
71
71
|
import type {
|
|
72
|
-
AcpAgentCatalog, AcpAgentDef, AcpApprovalDeadlines, AcpConversationChoice, AcpEvent, AcpMcpServerDef, AcpPresetStatus, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult, AgentView, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, GenerateOptions, LlmAdapterLike,
|
|
72
|
+
AcpAgentCatalog, AcpAgentDef, AcpApprovalDeadlines, AcpConversationChoice, AcpEvent, AcpMcpServerDef, AcpPresetStatus, AcpReleaseCause, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult, AgentView, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, GenerateOptions, LlmAdapterLike,
|
|
73
73
|
LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo, LlmReasoningEffortInfo, LlmResolvedModelInfo,
|
|
74
74
|
ApprovalDeadlineKey, ApprovalDeadlinePatch, PermissionOutcome, PermissionOptionView, PermissionTimeoutMode, PluginUpdateState, PreparedAdapterCall, SessionView, StreamChunk,
|
|
75
75
|
} from './protocol.ts'
|
|
@@ -637,7 +637,7 @@ export class AgentBridge {
|
|
|
637
637
|
this.touch()
|
|
638
638
|
return
|
|
639
639
|
}
|
|
640
|
-
this.releaseAll(`agent "${this.def.id}" was released after ${String(Math.round(this.currentIdleTimeoutMs / 1000))}s idle
|
|
640
|
+
this.releaseAll(`agent "${this.def.id}" was released after ${String(Math.round(this.currentIdleTimeoutMs / 1000))}s idle`, 'idle')
|
|
641
641
|
}
|
|
642
642
|
|
|
643
643
|
/**
|
|
@@ -835,7 +835,7 @@ export class AgentBridge {
|
|
|
835
835
|
if (this.state === 'ready' && this.conn !== undefined && this.spawnCwd !== spawnCwd && this.isQuiet()) {
|
|
836
836
|
const reason = `agent "${this.def.id}" restarts in ${spawnCwd} (the workspace of the session using it)`
|
|
837
837
|
this.hub.broadcast({ type: 'agent/notice', agentId: this.def.id, level: 'info', text: `acp: ${reason}` })
|
|
838
|
-
this.releaseAll(reason)
|
|
838
|
+
this.releaseAll(reason, 'workspace')
|
|
839
839
|
}
|
|
840
840
|
}
|
|
841
841
|
if (this.state === 'ready' && this.conn !== undefined) return Promise.resolve()
|
|
@@ -1291,13 +1291,13 @@ export class AgentBridge {
|
|
|
1291
1291
|
this.sessions.delete(session.sessionId)
|
|
1292
1292
|
session.released = true
|
|
1293
1293
|
this.released.set(session.sessionId, session)
|
|
1294
|
-
this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason: 'resume failed' })
|
|
1294
|
+
this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason: 'resume failed', cause: 'resume-failed' })
|
|
1295
1295
|
throw error
|
|
1296
1296
|
}
|
|
1297
1297
|
}
|
|
1298
1298
|
|
|
1299
1299
|
/** Release all live sessions (process died or went idle): keep them resumable, settle permissions, drop the connection. */
|
|
1300
|
-
releaseAll(reason: string): void {
|
|
1300
|
+
releaseAll(reason: string, cause: AcpReleaseCause): void {
|
|
1301
1301
|
this.clearIdleTimer()
|
|
1302
1302
|
for (const session of [...this.sessions.values()]) {
|
|
1303
1303
|
// A session already being closed is `closeSession`'s to delete. Marking
|
|
@@ -1317,7 +1317,7 @@ export class AgentBridge {
|
|
|
1317
1317
|
session.released = true
|
|
1318
1318
|
this.settlePermissions(session)
|
|
1319
1319
|
this.released.set(session.sessionId, session)
|
|
1320
|
-
this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason })
|
|
1320
|
+
this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason, cause })
|
|
1321
1321
|
}
|
|
1322
1322
|
this.sessions.clear()
|
|
1323
1323
|
this.acpIndex.clear()
|
|
@@ -5019,7 +5019,7 @@ export class BridgeManager {
|
|
|
5019
5019
|
// here (their `runPrompt` promise also rejects into settle, but this also
|
|
5020
5020
|
// covers a promise the connection teardown leaves pending).
|
|
5021
5021
|
this.disposeTurnStreamsOf(controller => controller.session.agentId === bridge.def.id)
|
|
5022
|
-
bridge.releaseAll(reason)
|
|
5022
|
+
bridge.releaseAll(reason, 'died')
|
|
5023
5023
|
}
|
|
5024
5024
|
|
|
5025
5025
|
agentViews(): AgentView[] {
|
|
@@ -5737,7 +5737,7 @@ export class BridgeManager {
|
|
|
5737
5737
|
// rejection does not broadcast a second `turn/end` for a turn already
|
|
5738
5738
|
// finalized by the watchdog (`LiveSession.reclaimedTurnSeq`).
|
|
5739
5739
|
session.reclaimedTurnSeq = session.turnSeq
|
|
5740
|
-
bridge.releaseAll('agent did not acknowledge the stalled turn')
|
|
5740
|
+
bridge.releaseAll('agent did not acknowledge the stalled turn', 'stalled')
|
|
5741
5741
|
session.orphanPromptTurnSeq = undefined
|
|
5742
5742
|
return { ok: true }
|
|
5743
5743
|
}
|
package/src/client/panel.tsx
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
|
19
19
|
import { formatCommandLine, isAbsolutePathLike, splitCommandLine } from './acp-ui.tsx'
|
|
20
|
-
import { DEFAULT_KNOWN_PRESETS } from './store.ts'
|
|
20
|
+
import { DEFAULT_KNOWN_PRESETS, LOG_MAX_ENTRIES } from './store.ts'
|
|
21
21
|
import type { AcpStore, UiState } from './store.ts'
|
|
22
22
|
import type { AcpAgentDef, AcpApprovalDeadlines, AcpCatalogChoice, AcpMcpServerDef, AgentSavePayload, AgentView, DetectedAgentInfo, PermissionTimeoutMode, PluginUpdateState } from '../protocol.ts'
|
|
23
23
|
|
|
@@ -251,6 +251,17 @@ const STYLE = `
|
|
|
251
251
|
.dwa-deadline-flag { margin-top: 4px; font-size: var(--ntr-font-xs); font-weight: 550; color: var(--ntr-warn); }
|
|
252
252
|
.dwa-deadline-warn { margin-top: 10px; padding: 9px 12px; border-left: 3px solid var(--dsw-alias-state-warn-primary, #d97706); background: var(--dsw-alias-state-warn-secondary, #fffbeb); border-radius: var(--ntr-radius-sm); font-size: var(--ntr-font-xs); line-height: 1.55; }
|
|
253
253
|
.dwa-deadline-reset { display: flex; justify-content: flex-end; margin-top: 8px; }
|
|
254
|
+
/* Runtime log: one bounded list, newest first. The time column never wraps;
|
|
255
|
+
the message is the only cell that may break anywhere. */
|
|
256
|
+
.dwa-log-list { list-style: none; margin: 0; padding: 0; border: var(--ntr-line); border-radius: var(--ntr-radius); overflow: hidden; }
|
|
257
|
+
.dwa-log-row { display: flex; align-items: baseline; gap: 8px; padding: 6px 10px; font-size: var(--ntr-font-sm); border-top: var(--ntr-line); min-width: 0; }
|
|
258
|
+
.dwa-log-row:first-child { border-top: 0; }
|
|
259
|
+
.dwa-log-row.is-error { background: var(--dsw-alias-state-error-tertiary, #fef2f2); }
|
|
260
|
+
.dwa-log-time { color: var(--ntr-muted); white-space: nowrap; }
|
|
261
|
+
.dwa-log-text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
|
262
|
+
.dwa-log-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ntr-danger); display: inline-block; margin-left: 6px; }
|
|
263
|
+
.dwa-log-level.is-info { background: var(--dsw-alias-state-business-tertiary, #dbeafe); color: var(--dsw-alias-state-business-primary, #2563eb); }
|
|
264
|
+
.dwa-log-level.is-error { background: var(--dsw-alias-state-error-tertiary, #fef2f2); color: var(--ntr-danger); }
|
|
254
265
|
@media (max-width: 640px) {
|
|
255
266
|
.dwa-root { padding: 20px 16px; }
|
|
256
267
|
}
|
|
@@ -866,7 +877,19 @@ function ApprovalDeadlinesCard(props: { acp: AcpStore; ui: UiState }) {
|
|
|
866
877
|
)
|
|
867
878
|
}
|
|
868
879
|
|
|
869
|
-
type SettingsPage = 'agents' | 'add' | 'approvals' | 'appearance' | 'diagnostics'
|
|
880
|
+
type SettingsPage = 'agents' | 'add' | 'approvals' | 'appearance' | 'logs' | 'diagnostics'
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Local wall-clock `HH:MM:SS` for one runtime-log line. Hand-built on purpose:
|
|
884
|
+
* `toLocaleTimeString` may drop seconds or pick a 12-hour clock depending on
|
|
885
|
+
* the browser locale, which makes the column ragged and ambiguous.
|
|
886
|
+
*/
|
|
887
|
+
function logClock(at: number): string {
|
|
888
|
+
const date = new Date(at)
|
|
889
|
+
return [date.getHours(), date.getMinutes(), date.getSeconds()]
|
|
890
|
+
.map(value => String(value).padStart(2, '0'))
|
|
891
|
+
.join(':')
|
|
892
|
+
}
|
|
870
893
|
|
|
871
894
|
/** The states that carry an update badge next to the version badge. */
|
|
872
895
|
function updateBadgeText(update: PluginUpdateState, acp: AcpStore): string | undefined {
|
|
@@ -1033,6 +1056,11 @@ export function AcpSettingsSection(props: WithStore) {
|
|
|
1033
1056
|
useEffect(() => {
|
|
1034
1057
|
void acp.checkPluginUpdate()
|
|
1035
1058
|
}, [acp])
|
|
1059
|
+
// Opening the log is the read receipt. The store does not mutate at zero,
|
|
1060
|
+
// so this cannot loop.
|
|
1061
|
+
useEffect(() => {
|
|
1062
|
+
if (page === 'logs' && ui.logUnread > 0) acp.markLogsRead()
|
|
1063
|
+
}, [acp, page, ui.logUnread])
|
|
1036
1064
|
const { configured, available } = partitionAgents(ui.detectedAgents, ui.agents)
|
|
1037
1065
|
const selected = addTarget ?? available[0] ?? NEW_AGENT_SEED
|
|
1038
1066
|
const openAdd = (): void => {
|
|
@@ -1154,6 +1182,10 @@ export function AcpSettingsSection(props: WithStore) {
|
|
|
1154
1182
|
<button type="button" aria-pressed={page === 'agents'} onClick={() => setPage('agents')}>{acp.label('智能体', 'Agents')}</button>
|
|
1155
1183
|
{ui.approvalDeadlines !== undefined && <button type="button" aria-pressed={page === 'approvals'} onClick={() => setPage('approvals')}>{acp.label('审批时限', 'Approval deadlines')}</button>}
|
|
1156
1184
|
<button type="button" aria-pressed={page === 'appearance'} onClick={() => setPage('appearance')}>{acp.label('对话外观', 'Appearance')}</button>
|
|
1185
|
+
<button type="button" aria-pressed={page === 'logs'} onClick={() => setPage('logs')}>
|
|
1186
|
+
{acp.label('日志', 'Logs')}
|
|
1187
|
+
{ui.logUnread > 0 && <span className="dwa-log-dot" aria-hidden="true" />}
|
|
1188
|
+
</button>
|
|
1157
1189
|
</nav>
|
|
1158
1190
|
{page === 'diagnostics' && (
|
|
1159
1191
|
<section className="dwa-section">
|
|
@@ -1334,6 +1366,45 @@ export function AcpSettingsSection(props: WithStore) {
|
|
|
1334
1366
|
)}
|
|
1335
1367
|
</div>
|
|
1336
1368
|
</section>
|
|
1369
|
+
{/* Conditional on purpose: this list can hold 200 rows, and keeping it
|
|
1370
|
+
mounted through a `hidden` page would re-render every line on each
|
|
1371
|
+
`session/update`. It carries no local state, so remounting is free. */}
|
|
1372
|
+
{page === 'logs' && (
|
|
1373
|
+
<section className="dwa-section" aria-label={acp.label('运行日志', 'Runtime log')}>
|
|
1374
|
+
<div className="dwa-section-heading">
|
|
1375
|
+
<div>
|
|
1376
|
+
<h3 className="dwa-section-title">{acp.label('运行日志', 'Runtime log')}</h3>
|
|
1377
|
+
<p className="dwa-section-copy">
|
|
1378
|
+
{acp.label(
|
|
1379
|
+
`agent 运行消息,最多 ${String(LOG_MAX_ENTRIES)} 条。`,
|
|
1380
|
+
`Agent runtime messages, up to ${String(LOG_MAX_ENTRIES)}.`,
|
|
1381
|
+
)}
|
|
1382
|
+
</p>
|
|
1383
|
+
</div>
|
|
1384
|
+
{ui.logs.length > 0 && (
|
|
1385
|
+
<button type="button" className="dwa-btn dwa-btn-quiet" onClick={() => acp.clearLogs()}>
|
|
1386
|
+
{acp.label('清空', 'Clear')}
|
|
1387
|
+
</button>
|
|
1388
|
+
)}
|
|
1389
|
+
</div>
|
|
1390
|
+
{ui.logs.length === 0
|
|
1391
|
+
? <div className="dwa-empty" role="status">{acp.label('暂无运行日志。', 'No runtime messages yet.')}</div>
|
|
1392
|
+
: (
|
|
1393
|
+
<ul className="dwa-log-list">
|
|
1394
|
+
{[...ui.logs].reverse().map(entry => (
|
|
1395
|
+
<li key={entry.id} className={entry.level === 'error' ? 'dwa-log-row is-error' : 'dwa-log-row'}>
|
|
1396
|
+
<span className="dwa-log-time dwa-mono">{logClock(entry.at)}</span>
|
|
1397
|
+
<span className={`dwa-badge dwa-log-level ${entry.level === 'error' ? 'is-error' : 'is-info'}`}>
|
|
1398
|
+
{entry.level === 'error' ? acp.label('错误', 'Error') : acp.label('信息', 'Info')}
|
|
1399
|
+
</span>
|
|
1400
|
+
{entry.agentId !== undefined && <span className="dwa-badge">{entry.agentId}</span>}
|
|
1401
|
+
<span className="dwa-log-text">{entry.text}</span>
|
|
1402
|
+
</li>
|
|
1403
|
+
))}
|
|
1404
|
+
</ul>
|
|
1405
|
+
)}
|
|
1406
|
+
</section>
|
|
1407
|
+
)}
|
|
1337
1408
|
<footer className="dwa-health">
|
|
1338
1409
|
<div className="dwa-health-status">
|
|
1339
1410
|
<span className="dwa-dot" style={{ background: ui.stream === 'live' ? 'var(--ntr-success)' : ui.stream === 'down' ? 'var(--ntr-danger)' : 'var(--ntr-muted)' }} />
|
package/src/client/store.ts
CHANGED
|
@@ -135,6 +135,22 @@ export interface ChatTranscript {
|
|
|
135
135
|
seq: number
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* One line of the settings page's ACP runtime log. Agent-side notices and
|
|
140
|
+
* session-release reasons land here (bounded, append order oldest→newest);
|
|
141
|
+
* failures of the settings page's OWN actions stay in {@link UiState.notice}.
|
|
142
|
+
*/
|
|
143
|
+
export interface AcpLogEntry {
|
|
144
|
+
id: number
|
|
145
|
+
at: number
|
|
146
|
+
level: 'info' | 'error'
|
|
147
|
+
text: string
|
|
148
|
+
agentId?: string
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Cap on retained runtime-log lines; older entries fall off the front. */
|
|
152
|
+
export const LOG_MAX_ENTRIES = 200
|
|
153
|
+
|
|
138
154
|
export interface UiState {
|
|
139
155
|
open: boolean
|
|
140
156
|
/**
|
|
@@ -156,7 +172,15 @@ export interface UiState {
|
|
|
156
172
|
blocks: Block[]
|
|
157
173
|
permissions: PendingPermission[]
|
|
158
174
|
busy: boolean
|
|
175
|
+
/**
|
|
176
|
+
* The settings page's one-line banner for its OWN action failures. Agent-side
|
|
177
|
+
* runtime messages do not land here; they append to {@link logs} instead.
|
|
178
|
+
*/
|
|
159
179
|
notice?: string
|
|
180
|
+
/** Agent-side runtime log, append order oldest→newest, capped at {@link LOG_MAX_ENTRIES}. */
|
|
181
|
+
logs: AcpLogEntry[]
|
|
182
|
+
/** Lines appended since the user last opened the log; reset by {@link AcpStore.markLogsRead}. */
|
|
183
|
+
logUnread: number
|
|
160
184
|
/**
|
|
161
185
|
* The plugin's OWN default ACP agent (the panel's ★ gesture) — which agent a
|
|
162
186
|
* new ACP-mode conversation starts on. NOT the deployment default model: that
|
|
@@ -603,6 +627,8 @@ export class AcpStore {
|
|
|
603
627
|
blocks: [],
|
|
604
628
|
permissions: [],
|
|
605
629
|
busy: false,
|
|
630
|
+
logs: [],
|
|
631
|
+
logUnread: 0,
|
|
606
632
|
detectedAgents: [...DEFAULT_KNOWN_PRESETS],
|
|
607
633
|
detecting: false,
|
|
608
634
|
modelCatalogs: {},
|
|
@@ -625,6 +651,8 @@ export class AcpStore {
|
|
|
625
651
|
|
|
626
652
|
private readonly listeners = new Set<Listener>()
|
|
627
653
|
private source?: EventSource
|
|
654
|
+
/** Monotonic id source for {@link AcpLogEntry}s (never reset by `clearLogs`). */
|
|
655
|
+
private logSeq = 0
|
|
628
656
|
private readonly lastSeq = new Map<string, number>()
|
|
629
657
|
/** Sessions with a finalized turn (dedups the cancel-fallback + late real turn/end pair). */
|
|
630
658
|
private readonly openTurns = new Set<string>()
|
|
@@ -712,6 +740,24 @@ export class AcpStore {
|
|
|
712
740
|
for (const listener of [...this.listeners]) listener()
|
|
713
741
|
}
|
|
714
742
|
|
|
743
|
+
/**
|
|
744
|
+
* Build the log patch for one line. Does NOT mutate: the caller merges it
|
|
745
|
+
* into the single `mutate` call it was already making for the same event.
|
|
746
|
+
*/
|
|
747
|
+
private logPatch(level: 'info' | 'error', text: string, agentId?: string): Pick<UiState, 'logs' | 'logUnread'> {
|
|
748
|
+
const entry: AcpLogEntry = {
|
|
749
|
+
id: ++this.logSeq,
|
|
750
|
+
at: Date.now(),
|
|
751
|
+
level,
|
|
752
|
+
text,
|
|
753
|
+
...(agentId === undefined ? {} : { agentId }),
|
|
754
|
+
}
|
|
755
|
+
return {
|
|
756
|
+
logs: [...this.state.logs, entry].slice(-LOG_MAX_ENTRIES),
|
|
757
|
+
logUnread: this.state.logUnread + 1,
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
715
761
|
// ---- Streaming update coalescing ----
|
|
716
762
|
|
|
717
763
|
/**
|
|
@@ -1895,6 +1941,17 @@ export class AcpStore {
|
|
|
1895
1941
|
this.mutate({ notice: undefined })
|
|
1896
1942
|
}
|
|
1897
1943
|
|
|
1944
|
+
/** Drop every retained runtime-log line (the unread counter restarts too). */
|
|
1945
|
+
clearLogs(): void {
|
|
1946
|
+
this.mutate({ logs: [], logUnread: 0 })
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
/** The user looked at the log: clear the unread badge. */
|
|
1950
|
+
markLogsRead(): void {
|
|
1951
|
+
if (this.state.logUnread === 0) return
|
|
1952
|
+
this.mutate({ logUnread: 0 })
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1898
1955
|
private flashError(error: unknown): void {
|
|
1899
1956
|
this.mutate({ notice: error instanceof Error ? error.message : String(error) })
|
|
1900
1957
|
}
|
|
@@ -1904,7 +1961,7 @@ export class AcpStore {
|
|
|
1904
1961
|
private applyEvent(event: AcpEvent): Promise<void> {
|
|
1905
1962
|
switch (event.type) {
|
|
1906
1963
|
case 'agent/notice': {
|
|
1907
|
-
this.mutate(
|
|
1964
|
+
this.mutate(this.logPatch(event.level, event.text, event.agentId))
|
|
1908
1965
|
return Promise.resolve()
|
|
1909
1966
|
}
|
|
1910
1967
|
case 'agent/status': {
|
|
@@ -1934,6 +1991,7 @@ export class AcpStore {
|
|
|
1934
1991
|
return Promise.resolve()
|
|
1935
1992
|
}
|
|
1936
1993
|
case 'session/closed': {
|
|
1994
|
+
const closingSession = this.state.sessions.find(s => s.sessionId === event.sessionId)
|
|
1937
1995
|
const sessions = this.state.sessions.filter(s => s.sessionId !== event.sessionId)
|
|
1938
1996
|
const active = this.state.activeSessionId === event.sessionId ? sessions[0]?.sessionId : this.state.activeSessionId
|
|
1939
1997
|
this.openTurns.delete(event.sessionId)
|
|
@@ -1949,7 +2007,9 @@ export class AcpStore {
|
|
|
1949
2007
|
chatTranscripts: closedTranscripts,
|
|
1950
2008
|
...(chatSessions.length !== this.state.chatSessions.length ? { chatSessions } : {}),
|
|
1951
2009
|
permissions: this.state.permissions.filter(p => p.sessionId !== event.sessionId),
|
|
1952
|
-
...(event.reason === 'agent-died'
|
|
2010
|
+
...(event.reason === 'agent-died'
|
|
2011
|
+
? this.logPatch('error', this.locale('会话因 agent 进程退出而终止', 'session ended: agent process died'), closingSession?.agentId)
|
|
2012
|
+
: {}),
|
|
1953
2013
|
})
|
|
1954
2014
|
return Promise.resolve()
|
|
1955
2015
|
}
|
|
@@ -1966,6 +2026,31 @@ export class AcpStore {
|
|
|
1966
2026
|
const chatSessions = this.state.chatSessions.some(s => s.sessionId === event.sessionId)
|
|
1967
2027
|
? this.state.chatSessions.map(s => (s.sessionId === event.sessionId ? { ...s, released: true } : s))
|
|
1968
2028
|
: this.state.chatSessions
|
|
2029
|
+
// One runtime-log line per release cause; `idle` stays silent on
|
|
2030
|
+
// purpose (an expected lifecycle transition, not a fault).
|
|
2031
|
+
const cause = event.cause ?? 'died'
|
|
2032
|
+
let log: Partial<Pick<UiState, 'logs' | 'logUnread'>> = {}
|
|
2033
|
+
if (cause === 'workspace') {
|
|
2034
|
+
log = this.logPatch('info', this.locale(
|
|
2035
|
+
'agent 为切换工作区重启,会话已挂起;再次发送消息将自动恢复。',
|
|
2036
|
+
'agent restarted for its workspace; the session is paused and will resume on your next message.',
|
|
2037
|
+
), event.session.agentId)
|
|
2038
|
+
} else if (cause === 'resume-failed') {
|
|
2039
|
+
log = this.logPatch('error', this.locale(
|
|
2040
|
+
'会话恢复失败;再次发送消息将重试恢复。',
|
|
2041
|
+
'session resume failed; your next message will retry it.',
|
|
2042
|
+
), event.session.agentId)
|
|
2043
|
+
} else if (cause === 'stalled') {
|
|
2044
|
+
log = this.logPatch('error', this.locale(
|
|
2045
|
+
'agent 未响应卡死的回合,会话已挂起;再次发送消息将自动恢复。',
|
|
2046
|
+
'agent did not acknowledge the stalled turn; the session is paused and will resume on your next message.',
|
|
2047
|
+
), event.session.agentId)
|
|
2048
|
+
} else if (cause === 'died') {
|
|
2049
|
+
log = this.logPatch('error', this.locale(
|
|
2050
|
+
'agent 进程已退出,会话已挂起;再次发送消息将自动恢复。',
|
|
2051
|
+
'agent process died; the session is paused and will resume on your next message.',
|
|
2052
|
+
), event.session.agentId)
|
|
2053
|
+
}
|
|
1969
2054
|
this.mutate({
|
|
1970
2055
|
sessions,
|
|
1971
2056
|
...(chatSessions !== this.state.chatSessions ? { chatSessions } : {}),
|
|
@@ -1973,10 +2058,7 @@ export class AcpStore {
|
|
|
1973
2058
|
// An observed transcript keeps its buffer but flips to released, so
|
|
1974
2059
|
// the ACP view can say "会恢复" instead of silently looking dead.
|
|
1975
2060
|
...this.watchedSessionPatch(event.sessionId, event.session),
|
|
1976
|
-
|
|
1977
|
-
'agent 进程已退出,会话已挂起;再次发送消息将自动恢复。',
|
|
1978
|
-
'agent process died; the session is paused and will resume on your next message.',
|
|
1979
|
-
),
|
|
2061
|
+
...log,
|
|
1980
2062
|
})
|
|
1981
2063
|
return Promise.resolve()
|
|
1982
2064
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -332,6 +332,12 @@ export interface AgentView {
|
|
|
332
332
|
idleTimeoutMs?: number
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Why the host released a session's agent process. The browser turns this into
|
|
337
|
+
* one runtime-log line per cause; `idle` stays silent (expected lifecycle).
|
|
338
|
+
*/
|
|
339
|
+
export type AcpReleaseCause = 'idle' | 'workspace' | 'resume-failed' | 'stalled' | 'died'
|
|
340
|
+
|
|
335
341
|
export type AcpEvent =
|
|
336
342
|
| { type: 'agent/status'; agentId: string; state: AgentProcessState; error?: string; agent?: AgentView }
|
|
337
343
|
| { type: 'agent/notice'; agentId: string; level: 'info' | 'error'; text: string }
|
|
@@ -344,6 +350,8 @@ export type AcpEvent =
|
|
|
344
350
|
sessionId: string
|
|
345
351
|
session: SessionView
|
|
346
352
|
reason: string
|
|
353
|
+
/** Host's release-cause classification; absent on an older host half. */
|
|
354
|
+
cause?: AcpReleaseCause
|
|
347
355
|
}
|
|
348
356
|
| {
|
|
349
357
|
/**
|
package/src/update.ts
CHANGED
|
@@ -91,22 +91,35 @@ function describeFieldError(error: { code?: string; message?: string; diagnostic
|
|
|
91
91
|
return parts.length > 0 ? parts.join(': ') : 'the host reported a bundle error'
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/** The version-spec half of a registry source: a range, wildcard, or dist-tag. */
|
|
95
|
+
function isRegistryRange(value: string): boolean {
|
|
96
|
+
if (value === '') return false
|
|
97
|
+
const lowered = value.toLowerCase()
|
|
98
|
+
if (lowered === '*' || lowered === 'x') return true
|
|
99
|
+
if (REGISTRY_TAGS.has(lowered)) return true
|
|
100
|
+
return REGISTRY_VERSION_SPEC.test(value)
|
|
101
|
+
}
|
|
102
|
+
|
|
94
103
|
/**
|
|
95
104
|
* Whether a `listBundles().source` value names a registry-resolvable version
|
|
96
|
-
* spec — the only install shape this updater may replace. The
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
105
|
+
* spec — the only install shape this updater may replace. The harness records
|
|
106
|
+
* a registry dependency as `<package>@<spec>` (`dependencySpec` in the plugin
|
|
107
|
+
* manager), so the spec half after the last `@` is what gets judged. The test
|
|
108
|
+
* is positive on purpose: link/file/git/workspace/URL/tarball/relative forms
|
|
109
|
+
* all carry a `:` / `/` / `\` or start with `.`, so they fall out without an
|
|
110
|
+
* ever-growing blacklist (a new source scheme must not become silently
|
|
111
|
+
* updatable).
|
|
100
112
|
*/
|
|
101
113
|
export function isRegistrySpec(source: string): boolean {
|
|
102
114
|
const value = typeof source === 'string' ? source.trim() : ''
|
|
103
115
|
if (value === '') return false
|
|
104
116
|
if (value.startsWith('.')) return false
|
|
105
117
|
if (value.includes(':') || value.includes('/') || value.includes('\\')) return false
|
|
106
|
-
const
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
118
|
+
const at = value.lastIndexOf('@')
|
|
119
|
+
if (at === -1) return isRegistryRange(value)
|
|
120
|
+
const name = value.slice(0, at)
|
|
121
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(name)) return false
|
|
122
|
+
return isRegistryRange(value.slice(at + 1))
|
|
110
123
|
}
|
|
111
124
|
|
|
112
125
|
/**
|