@prismshadow/penguin-server 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -36,8 +36,8 @@ pnpm --filter @prismshadow/penguin-server start # node dist/index.js
36
36
  ## Security notes (known MVP limits)
37
37
 
38
38
  - **CSRF**: session cookie is `SameSite=Lax` and writes accept only `Content-Type: application/json`; no CSRF token yet.
39
- - **No login rate limiting**: add throttling at a reverse proxy for public deployments.
40
- - **Built-in admin starts as `admin` / `penguin-2026`**: change it immediately (a banner keeps reminding until you do).
39
+ - **Login throttling**: per-username exponential backoff after 5 consecutive failures (1s doubling to a 60s cap, `429 too_many_attempts` inside the window, reset on success; in-memory, so a restart clears it). Unknown usernames are throttled identically, so it is not an account-existence oracle. A reverse proxy can still add IP-level limits for public deployments.
40
+ - **Built-in admin `admin` starts with a random initial password** of the form `penguin-1234`, printed once to the server console at seed time (`PENGUIN_SEED_ADMIN_PASSWORD` pins it for tests/e2e): change it immediately (a banner keeps reminding until you do).
41
41
  - Passwords use `node:crypto` scrypt (`scrypt$N$r$p$salt$hash`, timingSafeEqual); login sessions renew on a 7-day sliding window; the DB stores only the token's sha256.
42
42
  - Model credentials live in the Project's hidden 0600 config file; the API always masks them.
43
43
  - Behind a reverse proxy, disable response buffering for SSE paths (the server already sends `X-Accel-Buffering: no`) and forward `x-forwarded-proto` to enable Secure cookies.
@@ -49,17 +49,33 @@ interface AuthResponse {
49
49
  interface MeResponse {
50
50
  user: UserInfo;
51
51
  /**
52
- * Whether Workspace HTML previews open on a separate origin (see design §
53
- * "Workspace 文件预览"). False means this deployment has no usable preview origin —
52
+ * Whether Workspace HTML previews open on a separate origin (the loopback
53
+ * counterpart of the App host, or PENGUIN_PREVIEW_ORIGIN when set). False means this
54
+ * deployment has no usable preview origin —
54
55
  * the App is reached on something other than a loopback name and
55
56
  * PENGUIN_PREVIEW_ORIGIN is unset — so previews fall back to the same-origin sandbox,
56
57
  * where `localStorage`, cookies and third-party embeds do not work. Computed per
57
58
  * request, since it depends on the host the caller is using.
58
59
  */
59
60
  previewIsolated: boolean;
61
+ /**
62
+ * Whether this server runs in desktop mode (spawned by the desktop shell with
63
+ * PENGUIN_DESKTOP_TOKEN). The web app then hides the logout entry, the
64
+ * initial-password banner and the self-update entry, and omits the old-password
65
+ * field when changing the password.
66
+ */
67
+ desktopMode: boolean;
68
+ /**
69
+ * How THIS session was established. Distinct from desktopMode: a browser signed into a
70
+ * desktop-mode server holds a "password" session and must still provide the old
71
+ * password when changing it — only "desktop" sessions (opened by the shell's one-shot
72
+ * token) may omit it.
73
+ */
74
+ sessionVia: "password" | "desktop";
60
75
  }
61
76
  interface PasswordChangeRequest {
62
- oldPassword: string;
77
+ /** Omitted only by desktop-established sessions (desktop mode); required otherwise. */
78
+ oldPassword?: string;
63
79
  /** At least 8 characters. */
64
80
  newPassword: string;
65
81
  }
@@ -79,6 +95,51 @@ interface AdminPasswordResetRequest {
79
95
  /** New initial password (at least 8 characters); resets invalidate all of the user's sessions. */
80
96
  password: string;
81
97
  }
98
+ /**
99
+ * Admin-level server-global settings (SQLite server_settings):
100
+ * two independent proxy switches sharing one optional explicit address. In every
101
+ * on-state the effective NO_PROXY always includes localhost/127.0.0.1/::1 (loopback is
102
+ * never proxied), and changes apply to newly initiated connections/spawns immediately —
103
+ * no restart.
104
+ */
105
+ interface ServerSettings {
106
+ /**
107
+ * "Application uses the proxy" (default on): the server's own outbound traffic (LLM
108
+ * requests, the update check, image fetches). On with `proxyUrl` set = that address
109
+ * for both http and https; on without an address = the proxy environment variables
110
+ * HTTP_PROXY / HTTPS_PROXY (both spellings); off = always direct.
111
+ */
112
+ proxyForApp: boolean;
113
+ /**
114
+ * "Agent environment uses the proxy" (default on): agent command subprocess
115
+ * environments. On with `proxyUrl` set = HTTP_PROXY / HTTPS_PROXY (plus lowercase
116
+ * twins) injected as that address with the merged NO_PROXY, overriding inherited
117
+ * values; on without an address = the host environment passes through unchanged;
118
+ * off = the proxy variables are stripped (NO_PROXY kept).
119
+ */
120
+ proxyForAgent: boolean;
121
+ /**
122
+ * The shared explicit proxy address (canonical `http(s)://host[:port]`), or null =
123
+ * follow the proxy environment variables. When set it takes precedence over
124
+ * HTTP_PROXY / HTTPS_PROXY wherever the owning switch is on.
125
+ */
126
+ proxyUrl: string | null;
127
+ }
128
+ interface ServerSettingsResponse {
129
+ settings: ServerSettings;
130
+ }
131
+ /** PUT body: every field optional, omitted fields keep their current value (mirrors prefs). */
132
+ interface ServerSettingsUpdateRequest {
133
+ proxyForApp?: boolean;
134
+ proxyForAgent?: boolean;
135
+ /**
136
+ * New proxy address. Accepted forms: `http://host[:port]`, `https://host[:port]`, or
137
+ * bare `host[:port]` (normalized to `http://…` — only normalized values are stored,
138
+ * and the response echoes the stored form). Empty/whitespace-only or null clears the
139
+ * address (follow the environment variables); anything else is 400 `invalid_proxy_url`.
140
+ */
141
+ proxyUrl?: string | null;
142
+ }
82
143
  /** User UI preferences (SQLite ui_prefs, free-form JSON; known keys declared here). */
83
144
  interface UiPrefs {
84
145
  theme?: "light" | "dark";
@@ -91,6 +152,8 @@ interface UiPrefs {
91
152
  * scanning (#139).
92
153
  */
93
154
  showCliSessions?: boolean;
155
+ /** The initial-password notice banner (app layout) was permanently dismissed by the user. */
156
+ initialPasswordBannerDismissed?: boolean;
94
157
  [key: string]: unknown;
95
158
  }
96
159
  interface PrefsResponse {
@@ -290,6 +353,42 @@ interface ModelTestResponse {
290
353
  tps?: number;
291
354
  message?: string;
292
355
  }
356
+ /**
357
+ * PUT /api/projects/:p/models/default (owner): narrow default-model switch — flips the same
358
+ * top-level `default_model` the models page's whole-table PUT writes, without resending the
359
+ * table (and thus without touching credentials). The pair must name a configured model
360
+ * entry, exactly like the whole-table route's defaultModel validation.
361
+ */
362
+ interface DefaultModelUpdateRequest {
363
+ provider: string;
364
+ modelId: string;
365
+ }
366
+ /** Response mirrors what GET models reports as `defaultModel`. */
367
+ interface DefaultModelResponse {
368
+ defaultModel: ModelRefDto;
369
+ }
370
+ /**
371
+ * Per-Project new-chat defaults: prefill for the chat draft page. Every key is optional —
372
+ * an absent key means "not set" (the pre-existing behavior). Serves as the GET response,
373
+ * the PUT request body (whole-block replace: an omitted key clears it) and the PUT
374
+ * response (the stored block). The default MODEL is deliberately not here: it stays the
375
+ * top-level `default_model` served/written via the models routes (single-sourced with the
376
+ * models page).
377
+ */
378
+ interface ChatDefaultsDto {
379
+ /** Preselected Agent; must reference an existing Agent of the Project (400 unknown_agent). */
380
+ agentId?: string;
381
+ /** Prefilled Workspace directory; absent/empty = a temporary workspace. */
382
+ workspace?: string;
383
+ /** Prefilled approval mode; absent = the built-in "allow-all". */
384
+ approvalMode?: ApprovalMode;
385
+ /**
386
+ * Fallback thinking level for Agents whose config has no explicit `model.thinking_level`
387
+ * (resolution chain: Agent explicit > this project default > built-in "medium"). Never
388
+ * "none" — only the four selectable tiers.
389
+ */
390
+ thinkingLevel?: Exclude<ThinkingLevelName, "none">;
391
+ }
293
392
  /** Read-only vault entry display: key name + masked value; plaintext is never sent. */
294
393
  interface VaultEntryInfo {
295
394
  key: string;
@@ -326,12 +425,16 @@ interface AgentSummary {
326
425
  toolCount: number;
327
426
  /** Agent State version number (the `version` in system_config.yaml; treated as 1 if missing). */
328
427
  version: number;
428
+ /** Whether the config's kernel stamp is behind the current defaults generation (a missing stamp counts as outdated) — drives the list card's update hint. */
429
+ kernelOutdated: boolean;
329
430
  /** Vault key count (number of keys in agent_state/.vault.toml). */
330
431
  vaultKeyCount: number;
331
432
  /** Schedule count (number of .toml files under agent_state/schedule/, including invalid ones). */
332
433
  scheduleCount: number;
333
434
  /** Installed Skill count (number of agent_state/skills/<name>/ directories with a SKILL.md). */
334
435
  skillCount: number;
436
+ /** Memory count (topic files summed over the scope directories under agent_state/memory/, independent of the memory switch). */
437
+ memoryCount: number;
335
438
  }
336
439
  interface AgentsResponse {
337
440
  agents: AgentSummary[];
@@ -357,16 +460,69 @@ interface AgentCompactionConfigDto {
357
460
  mode?: "summarize" | "discard";
358
461
  prompt?: string;
359
462
  }
463
+ /** Memory config. All fields report effective values (a config with no `memory` section reads as enabled with the built-in prompts, matching core); the prompts are edited on the Memory tab. */
464
+ interface AgentMemoryConfigDto {
465
+ enabled: boolean;
466
+ /** The always-injected half of the `{{MEMORY}}` block (carries `{{USER_MEMORY_INDEX}}`; the User directory is literal text). */
467
+ prompt: string;
468
+ /** Appended only in a persistent Workspace (carries `{{WORKSPACE_MEMORY_INDEX}}` and the rendered `{{WORKSPACE_MEMORY_DIR}}` directory). */
469
+ workspacePrompt: string;
470
+ }
471
+ /**
472
+ * Vault prompt-injection config, edited on the Vault tab. `enabled` / `prompt` report
473
+ * effective values (a config with no `vault` section reads as enabled with the built-in
474
+ * prompt, matching core); the last two are read-only facts computed from the stored template.
475
+ */
476
+ interface AgentVaultConfigDto {
477
+ /** Whether the Vault section enters the model context (values are injected into subprocesses regardless). */
478
+ enabled: boolean;
479
+ /** The `{{VAULT}}` block (carries `{{VAULT_KEYS}}`). */
480
+ prompt: string;
481
+ /** Whether the stored template carries `{{VAULT}}`; POST …/vault/template-placeholder inserts (or migrates to) it explicitly. */
482
+ templateHasPlaceholder: boolean;
483
+ /** Whether the stored template still carries the legacy hardcoded # Vault section verbatim (a pre-`{{VAULT}}` Agent) — the migration case of the insert endpoint. */
484
+ legacySectionPresent: boolean;
485
+ }
486
+ /** Skills prompt-injection config, edited on the Skills tab; same field semantics as AgentVaultConfigDto, for `{{SKILLS}}` / `{{SKILL_METADATA}}` and the legacy # Skills section. */
487
+ interface AgentSkillsConfigDto {
488
+ /** Whether the Skills section enters the model context (installed skills remain explicitly invocable regardless). */
489
+ enabled: boolean;
490
+ /** The `{{SKILLS}}` block (carries `{{SKILL_METADATA}}`). */
491
+ prompt: string;
492
+ /** Whether the stored template carries `{{SKILLS}}`; POST …/skills/template-placeholder inserts (or migrates to) it explicitly. */
493
+ templateHasPlaceholder: boolean;
494
+ /** Whether the stored template still carries the legacy hardcoded # Skills section verbatim — the migration case of the insert endpoint. */
495
+ legacySectionPresent: boolean;
496
+ }
497
+ /** Schedules prompt-injection config, edited on the Schedules tab. No legacy field: Schedules never had a hardcoded template section. */
498
+ interface AgentSchedulesConfigDto {
499
+ /** Whether the Scheduled Tasks section enters the model context (the server fires configured tasks regardless). */
500
+ enabled: boolean;
501
+ /** The `{{SCHEDULES}}` block (carries `{{SCHEDULE_LIST}}`). */
502
+ prompt: string;
503
+ /** Whether the stored template carries `{{SCHEDULES}}`; POST …/schedules/template-placeholder inserts it explicitly. */
504
+ templateHasPlaceholder: boolean;
505
+ }
360
506
  /** Structured view of system_config.yaml (for the edit form). */
361
507
  interface AgentConfigDto {
362
508
  name?: string;
363
509
  description?: string;
364
510
  /** Agent State version number (treated as 1 if missing; shown in the settings page overview). */
365
511
  version: number;
512
+ /** The stored kernel stamp (`kernel_version`): which defaults generation the config is based on; null when the config predates the kernel-version mechanism. */
513
+ kernelVersion: string | null;
514
+ /** The current defaults generation (core's KERNEL_VERSION) — what a kernel update would stamp. */
515
+ kernelLatest: string;
516
+ /** Whether the stamp is behind kernelLatest (a missing stamp counts as outdated). */
517
+ kernelOutdated: boolean;
366
518
  systemPrompt: string;
367
519
  maxTurns?: number;
368
520
  model?: AgentModelConfigDto;
369
521
  compaction?: AgentCompactionConfigDto;
522
+ memory: AgentMemoryConfigDto;
523
+ vault: AgentVaultConfigDto;
524
+ skills: AgentSkillsConfigDto;
525
+ schedules: AgentSchedulesConfigDto;
370
526
  toolsBuiltin: ToolDefinitionConfig[];
371
527
  mcpServers: MCPServerConfig[];
372
528
  }
@@ -379,6 +535,29 @@ interface AgentConfigResponse {
379
535
  stateDir: string;
380
536
  activeSessionCount: number;
381
537
  }
538
+ /**
539
+ * POST …/config/kernel-update result: the smart merge's outcome (core's applyKernelUpdate).
540
+ * Paths are dotted config leaves (`system_prompt`, `memory.prompt`, `tools.builtin.<name>`…)
541
+ * in defaults-traversal order; the client maps them to display names.
542
+ */
543
+ interface AgentKernelUpdateResponse {
544
+ /** Leaves advanced to the new default (previously missing, or an untouched old default). */
545
+ advanced: string[];
546
+ /** Leaves kept because the stored value matches no recorded defaults generation (user customizations, kept conservatively). */
547
+ kept: string[];
548
+ /** The kernel stamp written (the current defaults generation). */
549
+ kernelVersion: string;
550
+ }
551
+ /** POST …/config/mcp-test result: reachability of one MCP Server entry. */
552
+ interface McpServerTestResponse {
553
+ ok: boolean;
554
+ /** Discovered tool names (`mcp__<server>__<tool>`), present on success. */
555
+ tools?: string[];
556
+ /** Failure detail (connect error, timeout, server stderr tail), present on failure. */
557
+ error?: string;
558
+ /** Connect + discovery wall time (both outcomes) — the models test reports latency, this matches. */
559
+ latencyMs?: number;
560
+ }
382
561
  /** PUT any subset: only provided keys are updated (remaining YAML content and comments preserved); agentsMd overwrites the whole file. */
383
562
  interface AgentConfigUpdateRequest {
384
563
  agentsMd?: string;
@@ -389,10 +568,73 @@ interface AgentConfigUpdateRequest {
389
568
  maxTurns?: number;
390
569
  model?: AgentModelConfigDto;
391
570
  compaction?: AgentCompactionConfigDto;
571
+ memory?: Partial<AgentMemoryConfigDto>;
572
+ /** Only the writable half of the DTO — the template facts (templateHasPlaceholder / legacySectionPresent) are computed, never written. */
573
+ vault?: {
574
+ enabled?: boolean;
575
+ prompt?: string;
576
+ };
577
+ skills?: {
578
+ enabled?: boolean;
579
+ prompt?: string;
580
+ };
581
+ schedules?: {
582
+ enabled?: boolean;
583
+ prompt?: string;
584
+ };
392
585
  toolsBuiltin?: ToolDefinitionConfig[];
393
586
  mcpServers?: MCPServerConfig[];
394
587
  };
395
588
  }
589
+ /** One Memory scope directory: `agent_state/memory/user/` or `agent_state/memory/<workspaceKey>/`. */
590
+ interface MemoryScopeInfo {
591
+ /** Directory name under `memory/`: `user`, or a Workspace's `<safe-basename>-<hash>` key. */
592
+ scopeKey: string;
593
+ /** `user` — the scope every Session reads, temporary Workspaces included; `workspace` — one Workspace's scope. */
594
+ kind: "user" | "workspace";
595
+ /** Workspace path the key was derived from, read from the directory's `.workspace` marker; unset on the user scope (it stands for no path) and for a directory edited by hand. */
596
+ workspacePath?: string;
597
+ /** Number of Markdown topic files in the directory (the `MEMORY.md` index not counted). */
598
+ fileCount: number;
599
+ /** Most recent topic-file mtime in the directory (ISO 8601); unset when the directory holds no topic file. */
600
+ updatedAt?: string;
601
+ }
602
+ /** One Memory topic file, as listed (frontmatter only — the body is fetched per file). */
603
+ interface MemoryFileInfo {
604
+ /** File name inside the scope directory, e.g. `prefers-pnpm.md`. */
605
+ name: string;
606
+ /** Frontmatter `name`; falls back to the file name. */
607
+ title: string;
608
+ /** Frontmatter `description`; empty when the file declares none. */
609
+ description: string;
610
+ /** Frontmatter `updated_at`, verbatim. */
611
+ updatedAt?: string;
612
+ /** File size in bytes. */
613
+ size: number;
614
+ /** File mtime (ISO 8601). */
615
+ modifiedAt: string;
616
+ }
617
+ /** GET …/memory — the tab's landing payload: the switch and every scope group, user scope first. */
618
+ interface MemoryOverviewResponse {
619
+ /** Whether Memory reaches the model context (the Agent-level switch). */
620
+ enabled: boolean;
621
+ /** Whether the prompt template carries the `{{MEMORY}}` placeholder. An Agent created before Memory has none and injects nothing; POST …/memory/template-placeholder inserts it explicitly. */
622
+ templateHasMemory: boolean;
623
+ /** Absolute path of `agent_state/memory/`. */
624
+ memoryDir: string;
625
+ scopes: MemoryScopeInfo[];
626
+ }
627
+ /** GET …/memory/scopes/:key/files */
628
+ interface MemoryFilesResponse {
629
+ scopeKey: string;
630
+ files: MemoryFileInfo[];
631
+ }
632
+ /** GET …/memory/scopes/:key/files/:name */
633
+ interface MemoryFileResponse {
634
+ scopeKey: string;
635
+ file: MemoryFileInfo;
636
+ content: string;
637
+ }
396
638
  interface SessionInfo {
397
639
  sessionId: string;
398
640
  projectId: string;
@@ -513,15 +755,57 @@ interface MessagesLiveTail {
513
755
  cursor: string;
514
756
  fragments: OmniMessage[];
515
757
  }
758
+ /**
759
+ * Pagination envelope of a windowed `GET /messages` (`tailLimit` / `before` requests
760
+ * only; the parameterless full read never carries it). A window is a run of whole
761
+ * message-bearing units — one unit = one Task in the Web reducer's sense, opened by a
762
+ * main-session user prompt — cut so that no pairing (tool_call/output), compaction span
763
+ * or steering group ever splits across windows.
764
+ */
765
+ interface MessagesPageInfo {
766
+ /**
767
+ * Cursor of this window's first unit (`<shardIndex>:<ordinal>`): pass it back as
768
+ * `before=` to fetch the previous window. Stable across requests and compaction —
769
+ * rotation opens a NEW shard and closed shards are immutable. Absent = this window
770
+ * reaches the very beginning of the transcript (no older history).
771
+ */
772
+ before?: string;
773
+ /**
774
+ * Outline turns (the Web conversation outline's entry rule) opened BEFORE this
775
+ * window: the client offsets its global "round N" numbering by this, so a partial
776
+ * window never mis-numbers. 0 when the window starts at the beginning.
777
+ */
778
+ earlierTurns: number;
779
+ /**
780
+ * Cumulative stats accrued before this window, seeded into the client's stats
781
+ * tracker so header chips and per-turn cumulative rows equal a full load:
782
+ * finished-Task elapsed, subagent token totals, and the last main-session
783
+ * session/context token readings.
784
+ */
785
+ prior: {
786
+ subagentTokens: number;
787
+ elapsedMs: number;
788
+ sessionTokens: number;
789
+ contextTokens: number;
790
+ };
791
+ }
516
792
  /** Message history: the full messages and events from concatenating all of this Session's Trace files in order (excludes partial_*). */
517
793
  interface MessagesResponse {
518
794
  messages: OmniMessage[];
519
795
  /**
520
796
  * Present only while the Session is running/compacting: the in-progress stream tail
521
797
  * (open streaming fragments + the channel cursor they cover), so a client joining
522
- * mid-stream can render the currently streaming message. Omitted when idle.
798
+ * mid-stream can render the currently streaming message. Omitted when idle. On
799
+ * windowed requests it rides TAIL pages only — a `before` page is immutable history
800
+ * and never carries it.
523
801
  */
524
802
  live?: MessagesLiveTail;
803
+ /**
804
+ * Present exactly on windowed requests (`tailLimit` / `before`): `messages` is then
805
+ * the requested window (subagent pointers inside it expanded as usual) rather than
806
+ * the full transcript. See MessagesPageInfo.
807
+ */
808
+ page?: MessagesPageInfo;
525
809
  }
526
810
  /**
527
811
  * A single Prompt's input parts: text, image (data: / http(s) URL), or an uploaded file.
@@ -647,6 +931,23 @@ interface ApprovalDecisionRequest {
647
931
  interface RetryNowResponse {
648
932
  skipped: boolean;
649
933
  }
934
+ /**
935
+ * One background command process started by the Session (an exec_command promoted past
936
+ * its yield window). Served from the ACTIVE runtime only: a session whose runtime entry
937
+ * is gone truthfully reports an empty list.
938
+ */
939
+ interface SessionProcessInfo {
940
+ processId: string;
941
+ /** OS pid of the process-group leader; null when the spawn itself failed. */
942
+ pid: number | null;
943
+ cmd: string;
944
+ cwd: string;
945
+ startedAt: string;
946
+ running: boolean;
947
+ }
948
+ interface SessionProcessesResponse {
949
+ processes: SessionProcessInfo[];
950
+ }
650
951
  /** Docs: /docs/server-api § "Streaming (SSE)". */
651
952
  type ServerEvent =
652
953
  /**
@@ -914,6 +1215,23 @@ interface TraceToolSpan {
914
1215
  /** The Task that initiated this tool (grouped with its tool_call segment): async output belongs to this Task even if it arrives after request_end. */
915
1216
  taskIndex: number;
916
1217
  }
1218
+ /**
1219
+ * Non-tool auxiliary phase on the timeline: rendered in its own lane under the "other"
1220
+ * legend category — deliberately not a tool span, since nothing was called. Currently the
1221
+ * first run's MCP connect + discovery (from the mcp_connect_begin/end event pair).
1222
+ */
1223
+ interface TraceOtherSpan {
1224
+ /** Unique bar key (e.g. `mcp-connect-<beginTs>`). */
1225
+ key: string;
1226
+ /** Lane label, e.g. "mcp connect". */
1227
+ name: string;
1228
+ startTs: string;
1229
+ endTs: string;
1230
+ /** Attached to the Task that follows the phase (same grouping convention as toolSpans). */
1231
+ taskIndex: number;
1232
+ /** True when the phase ended with failures (some servers unreachable) or was aborted. */
1233
+ failed?: boolean;
1234
+ }
917
1235
  interface UsageTrendPointInTrace {
918
1236
  ts: string;
919
1237
  requestTotal: number;
@@ -935,6 +1253,8 @@ interface TraceAnalysisResponse {
935
1253
  modelSegments: TraceModelSegment[];
936
1254
  /** Execution timeline: each tool's approval/execution phases (independent lane, can overlap with model decoding). */
937
1255
  toolSpans: TraceToolSpan[];
1256
+ /** Execution timeline: non-tool auxiliary phases (their own "other" lanes — currently the first run's MCP connect + discovery). */
1257
+ otherSpans: TraceOtherSpan[];
938
1258
  /** Number of request_end events with status ∈ {timeout, malformed}. */
939
1259
  reconnectCount: number;
940
1260
  /** Number of compaction_begin events. */
@@ -953,9 +1273,56 @@ interface AgentTraceDateGroup {
953
1273
  date: string;
954
1274
  sessions: AgentTraceSessionGroup[];
955
1275
  }
956
- /** Agent → date → Session → Trace file drill-down browsing structure (reverse chronological). */
1276
+ /** One Trace file in the session-centric listing (`date` carried per file: one Session's shards can span date directories). */
1277
+ interface AgentTraceSessionFile {
1278
+ index: number;
1279
+ date: string;
1280
+ sizeBytes: number;
1281
+ }
1282
+ /** One Session's Trace files merged across date directories (the paginated listing's unit). */
1283
+ interface AgentTraceSessionEntry {
1284
+ sessionId: string;
1285
+ /**
1286
+ * Display title, resolved only for the returned page: the sessions DB title when one
1287
+ * exists, else derived from the Session's first user prompt (bounded head-read of the
1288
+ * earliest shard); absent when neither yields one (the client falls back to its
1289
+ * default title — raw session ids are never rendered).
1290
+ */
1291
+ title?: string;
1292
+ /**
1293
+ * Sidebar category of this Session, from the same bounded classification the listing
1294
+ * filters and counts with: archived exactly from the DB row; origin from the shared
1295
+ * in-process registry / previously observed session_meta; a DB-untracked Session this
1296
+ * process has not yet head-read falls into `active` until a page surfaces it (its
1297
+ * head-read then registers the true origin for subsequent requests).
1298
+ */
1299
+ category: SessionCategory;
1300
+ /** Workspace path locked at creation (DB row or observed session_meta); "" when unknown — the client's merged temp-group fallback. */
1301
+ workspace: string;
1302
+ /** Sorted by index ascending (a higher index is newer). */
1303
+ files: AgentTraceSessionFile[];
1304
+ }
1305
+ /**
1306
+ * Agent-level Trace browsing structure. Without `limit` the response is the legacy full
1307
+ * drill-down (`dates`: Agent → date → Session → Trace file, reverse chronological) and the
1308
+ * paging fields are absent. With `offset`/`limit` the response is session-group-centric:
1309
+ * `sessions` carries the requested slice (newest first by sessionId desc — ids embed a
1310
+ * timestamp, so that is reverse chronological) with titles and classification,
1311
+ * `totalSessions` the session-group count (within `category` when one is given, so paging
1312
+ * and the count agree), `counts` / `workspaceCounts` the per-category totals over ALL of
1313
+ * the Agent's session groups (folder labels / workspace-mode group headers), and `dates`
1314
+ * stays empty (per-file stats are only taken for the returned page).
1315
+ */
957
1316
  interface AgentTracesResponse {
958
1317
  dates: AgentTraceDateGroup[];
1318
+ /** Present only when the request paginates: the requested slice of Session groups, newest first. */
1319
+ sessions?: AgentTraceSessionEntry[];
1320
+ /** Present only when the request paginates: session-group count of the paged (category-filtered) set. */
1321
+ totalSessions?: number;
1322
+ /** Present only when the request paginates: per-category totals over all of the Agent's session groups. */
1323
+ counts?: SessionCategoryCounts;
1324
+ /** Present only when the request paginates: `counts` broken down by Workspace path ("" = unknown). */
1325
+ workspaceCounts?: Record<string, SessionCategoryCounts>;
959
1326
  }
960
1327
  interface TraceImportRequest {
961
1328
  /** Base64 of the Trace file content (JSON Lines; the first record must be `session_meta`). */
@@ -1250,6 +1617,18 @@ interface AgentSkillsResponse {
1250
1617
  interface SkillInstallRequest {
1251
1618
  names: string[];
1252
1619
  }
1620
+ /**
1621
+ * POST /api/projects/:p/agents/:a/skills/archive: install one Skill from an uploaded zip.
1622
+ * Layout: SKILL.md at the zip root, or exactly one top-level directory containing SKILL.md
1623
+ * (the directory name is then the Skill name). 201 returns the refreshed installed list
1624
+ * (AgentSkillsResponse); an already-installed name without `overwrite` is 409 `skill_exists`.
1625
+ */
1626
+ interface SkillArchiveInstallRequest {
1627
+ /** Base64-encoded zip archive (decoded size capped at 14MB, same as the Agent snapshot import). */
1628
+ dataBase64: string;
1629
+ /** Replace an already-installed Skill of the same name (deletes its directory first). */
1630
+ overwrite?: boolean;
1631
+ }
1253
1632
  /** GET /api/version: the running server's release identity (from core's VERSION / BUILD_DATE). */
1254
1633
  interface VersionResponse {
1255
1634
  version: string;
@@ -1299,4 +1678,4 @@ interface UpdateRunResponse {
1299
1678
  needsRestart: boolean;
1300
1679
  }
1301
1680
 
1302
- export type { AdminPasswordResetRequest, AdminUserCreateRequest, AdminUserCreateResponse, AdminUsersResponse, AgentCompactionConfigDto, AgentConfigDto, AgentConfigResponse, AgentConfigUpdateRequest, AgentCreateRequest, AgentCreateResponse, AgentImportRequest, AgentImportResponse, AgentModelConfigDto, AgentSkillsResponse, AgentSummary, AgentTraceDateGroup, AgentTraceFileRef, AgentTraceSessionGroup, AgentTracesResponse, AgentsResponse, ApprovalDecisionRequest, ApprovalMode, AuthLoginRequest, AuthResponse, BenchmarkCaseScore, BenchmarkCaseSummary, BenchmarkCasesResponse, BenchmarkEvaluation, BenchmarkRunScore, BenchmarkSummary, BenchmarksResponse, CaseMaterial, CredentialInfo, DirEntryInfo, DirListResponse, ErrorBody, FilesStatRequest, FilesStatResponse, GoalResponse, GoalServerEvent, GoalStateView, MeResponse, MemberAddRequest, MemberAddResponse, MemberInfo, MembersResponse, MessagesLiveTail, MessagesResponse, ModelInfo, ModelPricingDto, ModelRefDto, ModelTestRequest, ModelTestResponse, ModelUpdateEntry, ModelsResponse, ModelsUpdateRequest, PasswordChangeRequest, PendingSteeringInfo, PrefsResponse, ProjectCreateRequest, ProjectCreateResponse, ProjectRole, ProjectSummary, ProjectUpdateRequest, ProjectUpdateResponse, ProjectsResponse, RequestSpan, RetryNowResponse, ScheduleItem, ScheduleServerEvent, ScheduleStatus, ScheduleUpsertRequest, SchedulesResponse, ServerEvent, SessionCategory, SessionCategoryCounts, SessionCreateRequest, SessionCreateResponse, SessionInfo, SessionPatchRequest, SessionResponse, SessionSource, SessionStatus, SessionTracesResponse, SessionsResponse, SkillGroupItem, SkillInstallRequest, SkillLibraryResponse, SkillMetadataItem, SteerRequest, TaskCreateRequest, TaskCreateResponse, TaskInputPart, ToolCallSpan, TraceAnalysisResponse, TraceEventsResponse, TraceFileInfo, TraceImportRequest, TraceImportResponse, TraceModelSegment, TraceTaskStats, TraceToolSpan, UiPrefs, UpdateCheckResponse, UpdateRunResponse, UsageAgentCount, UsageBucket, UsageErrorCount, UsageErrorItem, UsageErrors, UsageErrorsPage, UsageGroupBy, UsageGroupRow, UsageResponse, UsageSuccessRate, UsageTrendPoint, UsageTrendPointInTrace, UserInfo, VaultEntryInfo, VaultEntryUpdate, VaultResponse, VaultUpdateRequest, VersionResponse, WorkspaceFileEntry, WorkspaceFilesResponse };
1681
+ export type { AdminPasswordResetRequest, AdminUserCreateRequest, AdminUserCreateResponse, AdminUsersResponse, AgentCompactionConfigDto, AgentConfigDto, AgentConfigResponse, AgentConfigUpdateRequest, AgentCreateRequest, AgentCreateResponse, AgentImportRequest, AgentImportResponse, AgentKernelUpdateResponse, AgentMemoryConfigDto, AgentModelConfigDto, AgentSchedulesConfigDto, AgentSkillsConfigDto, AgentSkillsResponse, AgentSummary, AgentTraceDateGroup, AgentTraceFileRef, AgentTraceSessionEntry, AgentTraceSessionFile, AgentTraceSessionGroup, AgentTracesResponse, AgentVaultConfigDto, AgentsResponse, ApprovalDecisionRequest, ApprovalMode, AuthLoginRequest, AuthResponse, BenchmarkCaseScore, BenchmarkCaseSummary, BenchmarkCasesResponse, BenchmarkEvaluation, BenchmarkRunScore, BenchmarkSummary, BenchmarksResponse, CaseMaterial, ChatDefaultsDto, CredentialInfo, DefaultModelResponse, DefaultModelUpdateRequest, DirEntryInfo, DirListResponse, ErrorBody, FilesStatRequest, FilesStatResponse, GoalResponse, GoalServerEvent, GoalStateView, McpServerTestResponse, MeResponse, MemberAddRequest, MemberAddResponse, MemberInfo, MembersResponse, MemoryFileInfo, MemoryFileResponse, MemoryFilesResponse, MemoryOverviewResponse, MemoryScopeInfo, MessagesLiveTail, MessagesPageInfo, MessagesResponse, ModelInfo, ModelPricingDto, ModelRefDto, ModelTestRequest, ModelTestResponse, ModelUpdateEntry, ModelsResponse, ModelsUpdateRequest, PasswordChangeRequest, PendingSteeringInfo, PrefsResponse, ProjectCreateRequest, ProjectCreateResponse, ProjectRole, ProjectSummary, ProjectUpdateRequest, ProjectUpdateResponse, ProjectsResponse, RequestSpan, RetryNowResponse, ScheduleItem, ScheduleServerEvent, ScheduleStatus, ScheduleUpsertRequest, SchedulesResponse, ServerEvent, ServerSettings, ServerSettingsResponse, ServerSettingsUpdateRequest, SessionCategory, SessionCategoryCounts, SessionCreateRequest, SessionCreateResponse, SessionInfo, SessionPatchRequest, SessionProcessInfo, SessionProcessesResponse, SessionResponse, SessionSource, SessionStatus, SessionTracesResponse, SessionsResponse, SkillArchiveInstallRequest, SkillGroupItem, SkillInstallRequest, SkillLibraryResponse, SkillMetadataItem, SteerRequest, TaskCreateRequest, TaskCreateResponse, TaskInputPart, ToolCallSpan, TraceAnalysisResponse, TraceEventsResponse, TraceFileInfo, TraceImportRequest, TraceImportResponse, TraceModelSegment, TraceOtherSpan, TraceTaskStats, TraceToolSpan, UiPrefs, UpdateCheckResponse, UpdateRunResponse, UsageAgentCount, UsageBucket, UsageErrorCount, UsageErrorItem, UsageErrors, UsageErrorsPage, UsageGroupBy, UsageGroupRow, UsageResponse, UsageSuccessRate, UsageTrendPoint, UsageTrendPointInTrace, UserInfo, VaultEntryInfo, VaultEntryUpdate, VaultResponse, VaultUpdateRequest, VersionResponse, WorkspaceFileEntry, WorkspaceFilesResponse };
@@ -0,0 +1,76 @@
1
+ // src/lock.ts
2
+ import fs from "fs";
3
+ import net from "net";
4
+ import path from "path";
5
+ var PROBE_TIMEOUT_MS = 500;
6
+ function serverLockPath(root) {
7
+ return path.join(root, "server.lock");
8
+ }
9
+ function readServerLock(root) {
10
+ let raw;
11
+ try {
12
+ raw = fs.readFileSync(serverLockPath(root), "utf8");
13
+ } catch {
14
+ return null;
15
+ }
16
+ try {
17
+ const parsed = JSON.parse(raw);
18
+ if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || typeof parsed.port !== "number" || !Number.isInteger(parsed.port)) {
19
+ return null;
20
+ }
21
+ return { pid: parsed.pid, port: parsed.port, startedAt: String(parsed.startedAt ?? "") };
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+ function pidAlive(pid) {
27
+ try {
28
+ process.kill(pid, 0);
29
+ return true;
30
+ } catch (err) {
31
+ return err.code === "EPERM";
32
+ }
33
+ }
34
+ function portAccepts(port) {
35
+ return new Promise((resolve) => {
36
+ const socket = net.connect({ host: "127.0.0.1", port, timeout: PROBE_TIMEOUT_MS });
37
+ const done = (ok) => {
38
+ socket.destroy();
39
+ resolve(ok);
40
+ };
41
+ socket.once("connect", () => done(true));
42
+ socket.once("timeout", () => done(false));
43
+ socket.once("error", () => done(false));
44
+ });
45
+ }
46
+ async function isServerLockAlive(lock) {
47
+ return pidAlive(lock.pid) && await portAccepts(lock.port);
48
+ }
49
+ async function liveServerLock(root) {
50
+ const lock = readServerLock(root);
51
+ if (!lock) return null;
52
+ return await isServerLockAlive(lock) ? lock : null;
53
+ }
54
+ function acquireServerLock(root, lock) {
55
+ const target = serverLockPath(root);
56
+ const tmp = `${target}.${process.pid}.tmp`;
57
+ fs.writeFileSync(tmp, JSON.stringify(lock) + "\n");
58
+ fs.renameSync(tmp, target);
59
+ }
60
+ function releaseServerLock(root) {
61
+ try {
62
+ const lock = readServerLock(root);
63
+ if (lock && lock.pid === process.pid) fs.rmSync(serverLockPath(root));
64
+ } catch {
65
+ }
66
+ }
67
+
68
+ export {
69
+ serverLockPath,
70
+ readServerLock,
71
+ isServerLockAlive,
72
+ liveServerLock,
73
+ acquireServerLock,
74
+ releaseServerLock
75
+ };
76
+ //# sourceMappingURL=chunk-OMBFPEGQ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/lock.ts"],"sourcesContent":["/**\n * Root-level server instance lock (`<root>/server.lock`).\n *\n * web.db is single-process / single-writer (see db/database.ts), and two servers on one\n * data root would also double-run the schedule scheduler — so a data root admits one\n * server at a time. The lock records {pid, port, startedAt}; liveness requires BOTH the\n * pid to be alive AND the recorded port to accept a TCP connection, because either signal\n * alone false-positives (pids get recycled, ports get taken by unrelated processes). A\n * lock that fails the liveness check is stale and is simply overwritten by the next\n * server.\n *\n * Published as `@prismshadow/penguin-server/lock` (side-effect-free) so the CLI and the\n * desktop shell can pre-check a root without importing the package entry, which starts\n * listening.\n */\nimport fs from \"node:fs\";\nimport net from \"node:net\";\nimport path from \"node:path\";\n\nexport interface ServerLock {\n pid: number;\n port: number;\n startedAt: string;\n}\n\n/** TCP probe budget: loopback either connects immediately or the port is dead. */\nconst PROBE_TIMEOUT_MS = 500;\n\nexport function serverLockPath(root: string): string {\n return path.join(root, \"server.lock\");\n}\n\n/** Reads the lock file; a missing or malformed file reads as \"no lock\". */\nexport function readServerLock(root: string): ServerLock | null {\n let raw: string;\n try {\n raw = fs.readFileSync(serverLockPath(root), \"utf8\");\n } catch {\n return null;\n }\n try {\n const parsed = JSON.parse(raw) as Partial<ServerLock>;\n if (\n typeof parsed.pid !== \"number\" ||\n !Number.isInteger(parsed.pid) ||\n typeof parsed.port !== \"number\" ||\n !Number.isInteger(parsed.port)\n ) {\n return null;\n }\n return { pid: parsed.pid, port: parsed.port, startedAt: String(parsed.startedAt ?? \"\") };\n } catch {\n return null;\n }\n}\n\nfunction pidAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch (err) {\n // EPERM = the process exists but belongs to another user — still alive.\n return (err as NodeJS.ErrnoException).code === \"EPERM\";\n }\n}\n\nfunction portAccepts(port: number): Promise<boolean> {\n return new Promise((resolve) => {\n // 127.0.0.1 rather than localhost: this is a raw TCP liveness probe, not an App\n // request, and the server binds 127.0.0.1 (plus ::1) on loopback setups.\n const socket = net.connect({ host: \"127.0.0.1\", port, timeout: PROBE_TIMEOUT_MS });\n const done = (ok: boolean) => {\n socket.destroy();\n resolve(ok);\n };\n socket.once(\"connect\", () => done(true));\n socket.once(\"timeout\", () => done(false));\n socket.once(\"error\", () => done(false));\n });\n}\n\n/** True when the lock's process is alive AND its port accepts connections. */\nexport async function isServerLockAlive(lock: ServerLock): Promise<boolean> {\n return pidAlive(lock.pid) && (await portAccepts(lock.port));\n}\n\n/** Convenience for pre-checks: the live lock on this root, or null (absent or stale). */\nexport async function liveServerLock(root: string): Promise<ServerLock | null> {\n const lock = readServerLock(root);\n if (!lock) return null;\n return (await isServerLockAlive(lock)) ? lock : null;\n}\n\n/** Writes the lock atomically (tmp + rename; the parent directory must already exist). */\nexport function acquireServerLock(root: string, lock: ServerLock): void {\n const target = serverLockPath(root);\n const tmp = `${target}.${process.pid}.tmp`;\n fs.writeFileSync(tmp, JSON.stringify(lock) + \"\\n\");\n fs.renameSync(tmp, target);\n}\n\n/** Removes the lock if it is still ours (best-effort; never throws on shutdown paths). */\nexport function releaseServerLock(root: string): void {\n try {\n const lock = readServerLock(root);\n if (lock && lock.pid === process.pid) fs.rmSync(serverLockPath(root));\n } catch {\n // Best-effort: a stale leftover is overwritten by the next server anyway.\n }\n}\n"],"mappings":";AAeA,OAAO,QAAQ;AACf,OAAO,SAAS;AAChB,OAAO,UAAU;AASjB,IAAM,mBAAmB;AAElB,SAAS,eAAe,MAAsB;AACnD,SAAO,KAAK,KAAK,MAAM,aAAa;AACtC;AAGO,SAAS,eAAe,MAAiC;AAC9D,MAAI;AACJ,MAAI;AACF,UAAM,GAAG,aAAa,eAAe,IAAI,GAAG,MAAM;AAAA,EACpD,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI;AACF,UAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,QACE,OAAO,OAAO,QAAQ,YACtB,CAAC,OAAO,UAAU,OAAO,GAAG,KAC5B,OAAO,OAAO,SAAS,YACvB,CAAC,OAAO,UAAU,OAAO,IAAI,GAC7B;AACA,aAAO;AAAA,IACT;AACA,WAAO,EAAE,KAAK,OAAO,KAAK,MAAM,OAAO,MAAM,WAAW,OAAO,OAAO,aAAa,EAAE,EAAE;AAAA,EACzF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,SAAS,KAAsB;AACtC,MAAI;AACF,YAAQ,KAAK,KAAK,CAAC;AACnB,WAAO;AAAA,EACT,SAAS,KAAK;AAEZ,WAAQ,IAA8B,SAAS;AAAA,EACjD;AACF;AAEA,SAAS,YAAY,MAAgC;AACnD,SAAO,IAAI,QAAQ,CAAC,YAAY;AAG9B,UAAM,SAAS,IAAI,QAAQ,EAAE,MAAM,aAAa,MAAM,SAAS,iBAAiB,CAAC;AACjF,UAAM,OAAO,CAAC,OAAgB;AAC5B,aAAO,QAAQ;AACf,cAAQ,EAAE;AAAA,IACZ;AACA,WAAO,KAAK,WAAW,MAAM,KAAK,IAAI,CAAC;AACvC,WAAO,KAAK,WAAW,MAAM,KAAK,KAAK,CAAC;AACxC,WAAO,KAAK,SAAS,MAAM,KAAK,KAAK,CAAC;AAAA,EACxC,CAAC;AACH;AAGA,eAAsB,kBAAkB,MAAoC;AAC1E,SAAO,SAAS,KAAK,GAAG,KAAM,MAAM,YAAY,KAAK,IAAI;AAC3D;AAGA,eAAsB,eAAe,MAA0C;AAC7E,QAAM,OAAO,eAAe,IAAI;AAChC,MAAI,CAAC,KAAM,QAAO;AAClB,SAAQ,MAAM,kBAAkB,IAAI,IAAK,OAAO;AAClD;AAGO,SAAS,kBAAkB,MAAc,MAAwB;AACtE,QAAM,SAAS,eAAe,IAAI;AAClC,QAAM,MAAM,GAAG,MAAM,IAAI,QAAQ,GAAG;AACpC,KAAG,cAAc,KAAK,KAAK,UAAU,IAAI,IAAI,IAAI;AACjD,KAAG,WAAW,KAAK,MAAM;AAC3B;AAGO,SAAS,kBAAkB,MAAoB;AACpD,MAAI;AACF,UAAM,OAAO,eAAe,IAAI;AAChC,QAAI,QAAQ,KAAK,QAAQ,QAAQ,IAAK,IAAG,OAAO,eAAe,IAAI,CAAC;AAAA,EACtE,QAAQ;AAAA,EAER;AACF;","names":[]}