@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 +2 -2
- package/dist/api/types.d.ts +385 -6
- package/dist/chunk-OMBFPEGQ.js +76 -0
- package/dist/chunk-OMBFPEGQ.js.map +1 -0
- package/dist/chunk-UUJCF46V.js +50 -0
- package/dist/chunk-UUJCF46V.js.map +1 -0
- package/dist/index.js +4656 -2160
- package/dist/index.js.map +1 -1
- package/dist/initial-password.d.ts +17 -0
- package/dist/initial-password.js +15 -0
- package/dist/initial-password.js.map +1 -0
- package/dist/lock.d.ts +18 -0
- package/dist/lock.js +17 -0
- package/dist/lock.js.map +1 -0
- package/package.json +14 -4
- package/web-dist/assets/{index-BgHHHLga.js → index-B3puu-Hr.js} +1 -1
- package/web-dist/assets/index-CaDuyrN0.css +1 -0
- package/web-dist/assets/index-rgHg3m6g.js +181 -0
- package/web-dist/index.html +2 -2
- package/web-dist/assets/index-BOvgZOwz.js +0 -173
- package/web-dist/assets/index-DGdg-i_E.css +0 -1
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
|
-
- **
|
|
40
|
-
- **Built-in admin
|
|
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.
|
package/dist/api/types.d.ts
CHANGED
|
@@ -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 (
|
|
53
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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":[]}
|