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.
@@ -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
  }
@@ -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
@@ -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 test is positive
81
- * on purpose: link/file/git/workspace/URL/tarball/relative forms all carry a
82
- * `:` / `/` / `\` or start with `.`, so they fall out without an ever-growing
83
- * blacklist (a new source scheme must not become silently updatable).
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 test is positive
52
- * on purpose: link/file/git/workspace/URL/tarball/relative forms all carry a
53
- * `:` / `/` / `\` or start with `.`, so they fall out without an ever-growing
54
- * blacklist (a new source scheme must not become silently updatable).
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 lowered = value.toLowerCase();
65
- if (lowered === '*' || lowered === 'x')
66
- return true;
67
- if (REGISTRY_TAGS.has(lowered))
68
- return true;
69
- return REGISTRY_VERSION_SPEC.test(value);
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.0",
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
  }
@@ -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)' }} />
@@ -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({ notice: event.text })
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' ? { notice: this.locale('会话因 agent 进程退出而终止', 'session ended: agent process 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
- notice: this.locale(
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 test is positive
97
- * on purpose: link/file/git/workspace/URL/tarball/relative forms all carry a
98
- * `:` / `/` / `\` or start with `.`, so they fall out without an ever-growing
99
- * blacklist (a new source scheme must not become silently updatable).
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 lowered = value.toLowerCase()
107
- if (lowered === '*' || lowered === 'x') return true
108
- if (REGISTRY_TAGS.has(lowered)) return true
109
- return REGISTRY_VERSION_SPEC.test(value)
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
  /**