@xpufx/paseo-forges 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/client/active-forge.ts +47 -0
- package/client/board-alert.tsx +236 -0
- package/client/foreign-link.tsx +33 -0
- package/client/hook-queue-panel.tsx +412 -0
- package/client/issues-pill.tsx +1976 -0
- package/client/label-chip.tsx +116 -0
- package/client/linkifier.tsx +280 -0
- package/client/pill-label.ts +183 -0
- package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
- package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
- package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
- package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
- package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
- package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
- package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
- package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
- package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
- package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
- package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
- package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
- package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
- package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
- package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
- package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
- package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
- package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
- package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
- package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
- package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
- package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
- package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
- package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
- package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
- package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
- package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
- package/client/vendor/paseo-plugin-helper/host.ts +277 -0
- package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/index.ts +28 -0
- package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
- package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
- package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
- package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
- package/client/vendor/paseo-plugin-helper/query.ts +66 -0
- package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
- package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
- package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
- package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
- package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
- package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
- package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
- package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
- package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
- package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
- package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
- package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
- package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
- package/client/webhook-card.tsx +198 -0
- package/docs/specs/forge-workflow-gui.md +820 -0
- package/docs/workflow.md +339 -0
- package/examples/README.md +108 -0
- package/examples/hook-service/README.md +66 -0
- package/examples/hook-service/forge-hook.service +28 -0
- package/examples/hook-service/hook-server.mjs +265 -0
- package/examples/hook-service/hook.env.example +21 -0
- package/examples/labels/README.md +64 -0
- package/examples/labels/label-base.yaml +117 -0
- package/examples/skills/coding-agent/SKILL.md +262 -0
- package/examples/skills/coding-agent-fgjx/SKILL.md +271 -0
- package/examples/skills/orchestrator/SKILL.md +133 -0
- package/examples/skills/orchestrator-fgjx/SKILL.md +139 -0
- package/examples/tools/README.md +68 -0
- package/examples/tools/fgjx +464 -0
- package/package.json +38 -0
- package/paseo-plugin.json +4 -0
- package/server/forge-client.ts +484 -0
- package/server/forge-guard.ts +70 -0
- package/server/git-origin.ts +70 -0
- package/server/hook-queue.ts +127 -0
- package/server/issues.ts +542 -0
- package/server/settings.ts +52 -0
- package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
- package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
- package/server/vendor/paseo-plugin-helper/index.ts +18 -0
- package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
- package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
- package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
- package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
- package/server/vendor/paseo-plugin-helper/network.ts +91 -0
- package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
- package/server/vendor/paseo-plugin-helper/process.ts +186 -0
- package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
- package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
- package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
- package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
- package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
- package/server/vendor/paseo-plugin-helper/system.ts +128 -0
- package/server/vendor/paseo-plugin-helper/task.ts +116 -0
- package/server/vendor/paseo-plugin-helper/version.ts +153 -0
- package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
- package/shared/hook-queue.ts +140 -0
- package/shared/issues.ts +1750 -0
- package/shared/vendor/paseo-plugin-helper/README.md +11 -0
- package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
- package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
- package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
- package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
- package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
- package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
- package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
- package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
- package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
- package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
- package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
- package/shared/version.ts +2 -0
- package/shared/webhook.ts +223 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createPluginLogger, type PluginLogger } from "./logger";
|
|
2
|
+
|
|
3
|
+
export interface PeriodicTaskOptions {
|
|
4
|
+
intervalMs: number;
|
|
5
|
+
task: () => Promise<void> | void;
|
|
6
|
+
onError?: (err: unknown, failureCount: number) => void;
|
|
7
|
+
runImmediately?: boolean;
|
|
8
|
+
maxBackoffMs?: number;
|
|
9
|
+
/**
|
|
10
|
+
* Logger for suppressed failures. Defaults to a level-gated
|
|
11
|
+
* `periodic-task` logger so debug lines stay silent unless debug is enabled.
|
|
12
|
+
*/
|
|
13
|
+
logger?: PluginLogger;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PeriodicTaskHandle {
|
|
17
|
+
stop: () => void;
|
|
18
|
+
isRunning: () => boolean;
|
|
19
|
+
triggerNow: () => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Creates a resilient periodic background task loop for Paseo plugin daemons.
|
|
24
|
+
* Implements exponential backoff on consecutive failures and safe teardown hooks.
|
|
25
|
+
*/
|
|
26
|
+
export function createPeriodicTask(options: PeriodicTaskOptions): PeriodicTaskHandle {
|
|
27
|
+
const {
|
|
28
|
+
intervalMs,
|
|
29
|
+
task,
|
|
30
|
+
onError,
|
|
31
|
+
runImmediately = false,
|
|
32
|
+
maxBackoffMs = 60000,
|
|
33
|
+
} = options;
|
|
34
|
+
|
|
35
|
+
let timer: any = null;
|
|
36
|
+
let running = true;
|
|
37
|
+
let inFlight = false;
|
|
38
|
+
let failureCount = 0;
|
|
39
|
+
|
|
40
|
+
let taskLogger: PluginLogger | undefined;
|
|
41
|
+
function taskLog(): PluginLogger {
|
|
42
|
+
taskLogger ??= options.logger
|
|
43
|
+
? options.logger.child("periodic-task")
|
|
44
|
+
: createPluginLogger("periodic-task", { banner: false });
|
|
45
|
+
return taskLogger;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function execute() {
|
|
49
|
+
if (!running || inFlight) return;
|
|
50
|
+
inFlight = true;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
await task();
|
|
54
|
+
failureCount = 0;
|
|
55
|
+
} catch (err) {
|
|
56
|
+
failureCount++;
|
|
57
|
+
if (onError) {
|
|
58
|
+
try {
|
|
59
|
+
onError(err, failureCount);
|
|
60
|
+
} catch (suppressed) {
|
|
61
|
+
// Prevent onError handler from breaking task loop, but stay visible in dev logs.
|
|
62
|
+
taskLog().debug(
|
|
63
|
+
`onError handler failed: ${suppressed instanceof Error ? suppressed.message : String(suppressed)}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
taskLog().debug(
|
|
68
|
+
`suppressed error (failure ${failureCount}): ${err instanceof Error ? err.message : String(err)}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
} finally {
|
|
72
|
+
inFlight = false;
|
|
73
|
+
if (running) {
|
|
74
|
+
scheduleNext();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function scheduleNext() {
|
|
80
|
+
if (!running) return;
|
|
81
|
+
if (timer) clearTimeout(timer);
|
|
82
|
+
|
|
83
|
+
let delay = intervalMs;
|
|
84
|
+
if (failureCount > 0) {
|
|
85
|
+
// Exponential backoff with ceiling
|
|
86
|
+
const backoff = intervalMs * Math.pow(1.5, Math.min(failureCount, 8));
|
|
87
|
+
delay = Math.min(backoff, maxBackoffMs);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
timer = setTimeout(execute, delay);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (runImmediately) {
|
|
94
|
+
execute();
|
|
95
|
+
} else {
|
|
96
|
+
scheduleNext();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
stop: () => {
|
|
101
|
+
running = false;
|
|
102
|
+
if (timer) {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
timer = null;
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
isRunning: () => running,
|
|
108
|
+
triggerNow: async () => {
|
|
109
|
+
if (timer) {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
timer = null;
|
|
112
|
+
}
|
|
113
|
+
await execute();
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
export interface ResolveVersionOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Root directory of the plugin (defaults to `process.cwd()`).
|
|
8
|
+
*/
|
|
9
|
+
cwd?: string;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Whether to augment development versions with git commit short hash
|
|
13
|
+
* (e.g. "0.1.0-dev.bfe901a" if not an exact tag).
|
|
14
|
+
* Defaults to `true`.
|
|
15
|
+
*/
|
|
16
|
+
includeGit?: boolean;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Fallback string if neither package.json nor git metadata can be found.
|
|
20
|
+
* Defaults to "0.0.0".
|
|
21
|
+
*/
|
|
22
|
+
fallback?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Finds the nearest package.json starting from directory and moving up.
|
|
27
|
+
*/
|
|
28
|
+
function findPackageJson(startDir: string): { version?: string; name?: string } | null {
|
|
29
|
+
let current = path.resolve(startDir);
|
|
30
|
+
const root = path.parse(current).root;
|
|
31
|
+
|
|
32
|
+
while (current !== root) {
|
|
33
|
+
const pkgPath = path.join(current, "package.json");
|
|
34
|
+
if (fs.existsSync(pkgPath)) {
|
|
35
|
+
try {
|
|
36
|
+
const raw = fs.readFileSync(pkgPath, "utf8");
|
|
37
|
+
return JSON.parse(raw);
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const parent = path.dirname(current);
|
|
43
|
+
if (parent === current) break;
|
|
44
|
+
current = parent;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolves the plugin version from `package.json`, augmented by Git tag / commit hash.
|
|
52
|
+
*
|
|
53
|
+
* Precedence:
|
|
54
|
+
* 1. Exact Git tag on current commit (e.g. "v0.1.2" or "0.1.2")
|
|
55
|
+
* 2. `package.json` version (augmented with "-dev.hash" if in git and not on an exact release tag)
|
|
56
|
+
* 3. Git commit short hash fallback (e.g. "dev-bfe901a")
|
|
57
|
+
* 4. Fallback string (defaults to "0.0.0")
|
|
58
|
+
*/
|
|
59
|
+
export function resolvePluginVersion(options: ResolveVersionOptions = {}): string {
|
|
60
|
+
const {
|
|
61
|
+
cwd = process.cwd(),
|
|
62
|
+
includeGit = true,
|
|
63
|
+
fallback = "0.0.0",
|
|
64
|
+
} = options;
|
|
65
|
+
|
|
66
|
+
let pkgVersion: string | undefined;
|
|
67
|
+
|
|
68
|
+
// 1. Read package.json
|
|
69
|
+
const pkg = findPackageJson(cwd);
|
|
70
|
+
if (pkg?.version) {
|
|
71
|
+
pkgVersion = pkg.version;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!includeGit) {
|
|
75
|
+
return pkgVersion || fallback;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 2. Check if git is available and on an exact tag
|
|
79
|
+
try {
|
|
80
|
+
const gitTag = execSync("git describe --tags --exact-match 2>/dev/null", {
|
|
81
|
+
cwd,
|
|
82
|
+
encoding: "utf8",
|
|
83
|
+
timeout: 1000,
|
|
84
|
+
}).trim();
|
|
85
|
+
|
|
86
|
+
if (gitTag) {
|
|
87
|
+
// Strip leading 'v' if present for clean semantic format (e.g. "v0.1.2" -> "0.1.2")
|
|
88
|
+
return gitTag.startsWith("v") ? gitTag.slice(1) : gitTag;
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// Not on an exact tag or git not installed
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 3. If package.json has a version, check if we should add git short commit
|
|
95
|
+
try {
|
|
96
|
+
const gitHash = execSync("git rev-parse --short HEAD 2>/dev/null", {
|
|
97
|
+
cwd,
|
|
98
|
+
encoding: "utf8",
|
|
99
|
+
timeout: 1000,
|
|
100
|
+
}).trim();
|
|
101
|
+
|
|
102
|
+
if (gitHash) {
|
|
103
|
+
if (pkgVersion) {
|
|
104
|
+
return `${pkgVersion}+${gitHash}`;
|
|
105
|
+
}
|
|
106
|
+
return `0.0.0+${gitHash}`;
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// Not a git repository
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return pkgVersion || fallback;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface StampVersionOptions extends ResolveVersionOptions {
|
|
116
|
+
/**
|
|
117
|
+
* Destination TypeScript / JavaScript file path.
|
|
118
|
+
* Defaults to "./version.ts" or "./src/version.ts".
|
|
119
|
+
*/
|
|
120
|
+
targetFile?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Build-time utility to stamp the resolved version into a TypeScript file (e.g. `version.ts`),
|
|
125
|
+
* allowing client code (React Native) to import `PLUGIN_VERSION` directly without
|
|
126
|
+
* needing `node:fs` or `process.cwd()` at runtime.
|
|
127
|
+
*/
|
|
128
|
+
export function stampVersion(options: StampVersionOptions = {}): {
|
|
129
|
+
version: string;
|
|
130
|
+
targetFile: string;
|
|
131
|
+
updated: boolean;
|
|
132
|
+
} {
|
|
133
|
+
const cwd = options.cwd ?? process.cwd();
|
|
134
|
+
const targetFile = options.targetFile
|
|
135
|
+
? path.resolve(cwd, options.targetFile)
|
|
136
|
+
: path.resolve(cwd, "version.ts");
|
|
137
|
+
|
|
138
|
+
const version = resolvePluginVersion(options);
|
|
139
|
+
const content = `// Auto-generated by paseo-plugin-helper. Do not edit.\nexport const PLUGIN_VERSION = "${version}";\n`;
|
|
140
|
+
|
|
141
|
+
let existing = "";
|
|
142
|
+
if (fs.existsSync(targetFile)) {
|
|
143
|
+
existing = fs.readFileSync(targetFile, "utf8");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (existing !== content) {
|
|
147
|
+
fs.mkdirSync(path.dirname(targetFile), { recursive: true });
|
|
148
|
+
fs.writeFileSync(targetFile, content, "utf8");
|
|
149
|
+
return { version, targetFile, updated: true };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { version, targetFile, updated: false };
|
|
153
|
+
}
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import type { PluginLogger } from "./logger";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_BEACON_LABEL_PREFIX = "beacon:";
|
|
4
|
+
|
|
5
|
+
export const BEACON_COLORS = [
|
|
6
|
+
"violet",
|
|
7
|
+
"sky",
|
|
8
|
+
"emerald",
|
|
9
|
+
"orange",
|
|
10
|
+
"pink",
|
|
11
|
+
"indigo",
|
|
12
|
+
"teal",
|
|
13
|
+
"red",
|
|
14
|
+
"amber",
|
|
15
|
+
"blue",
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export type BeaconColor = (typeof BEACON_COLORS)[number];
|
|
19
|
+
|
|
20
|
+
export interface BeaconLabelState {
|
|
21
|
+
name: string;
|
|
22
|
+
color?: string;
|
|
23
|
+
titleSuffix?: string;
|
|
24
|
+
title?: string;
|
|
25
|
+
workspaceId?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BeaconSetOptions extends BeaconLabelState {
|
|
29
|
+
workspaceId: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BeaconBlinkOptions {
|
|
33
|
+
workspaceId?: string;
|
|
34
|
+
a: BeaconLabelState;
|
|
35
|
+
b: BeaconLabelState;
|
|
36
|
+
intervalMs?: number;
|
|
37
|
+
rounds?: number;
|
|
38
|
+
restoreOnDone?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface BeaconClearOptions {
|
|
42
|
+
workspaceId?: string;
|
|
43
|
+
name?: string;
|
|
44
|
+
restoreTitle?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface WorkspaceTitleHandle {
|
|
48
|
+
setTitle?: (title: string) => unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface BeaconDaemonClient {
|
|
52
|
+
setWorkspaceLabel?: (args: unknown) => unknown;
|
|
53
|
+
updateWorkspaceLabel?: (args: unknown) => unknown;
|
|
54
|
+
removeWorkspaceLabel?: (args: unknown) => unknown;
|
|
55
|
+
[key: string]: unknown;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface WorkspaceBeaconOptions {
|
|
59
|
+
workspaceHandle?: WorkspaceTitleHandle | null;
|
|
60
|
+
daemonClient?: BeaconDaemonClient | null;
|
|
61
|
+
labelPrefix?: string;
|
|
62
|
+
baseTitle?: string;
|
|
63
|
+
logger?: PluginLogger;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface BeaconSetResult {
|
|
67
|
+
labelApplied: boolean;
|
|
68
|
+
titleApplied: boolean;
|
|
69
|
+
labelName?: string;
|
|
70
|
+
title?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface BeaconClearResult {
|
|
74
|
+
labelCleared: boolean;
|
|
75
|
+
titleRestored: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface BeaconBlinkHandle {
|
|
79
|
+
stop: () => void;
|
|
80
|
+
done: Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function resolveBeaconLabelName(
|
|
84
|
+
name: string,
|
|
85
|
+
prefix: string = DEFAULT_BEACON_LABEL_PREFIX,
|
|
86
|
+
): string {
|
|
87
|
+
const trimmed = (name ?? "").trim();
|
|
88
|
+
if (!prefix) return trimmed;
|
|
89
|
+
if (trimmed.toLowerCase().startsWith(prefix.toLowerCase())) return trimmed;
|
|
90
|
+
return `${prefix}${trimmed}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function normalizeBeaconColor(color: string | undefined): string | undefined {
|
|
94
|
+
if (!color) return undefined;
|
|
95
|
+
const normalized = color.trim().toLowerCase();
|
|
96
|
+
if ((BEACON_COLORS as readonly string[]).includes(normalized)) return normalized;
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isFunction(value: unknown): value is (...args: never[]) => unknown {
|
|
101
|
+
return typeof value === "function";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function safeInvoke(
|
|
105
|
+
fn: () => unknown,
|
|
106
|
+
logger?: PluginLogger,
|
|
107
|
+
): Promise<boolean> {
|
|
108
|
+
try {
|
|
109
|
+
await fn();
|
|
110
|
+
return true;
|
|
111
|
+
} catch (err) {
|
|
112
|
+
logger?.debug?.(
|
|
113
|
+
`WorkspaceBeacon host call failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
114
|
+
);
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function toTimerKey(workspaceId: string | undefined): string {
|
|
120
|
+
return workspaceId ?? "__default__";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export class WorkspaceBeacon {
|
|
124
|
+
private workspaceHandle?: WorkspaceTitleHandle | null;
|
|
125
|
+
private daemonClient?: BeaconDaemonClient | null;
|
|
126
|
+
private labelPrefix: string;
|
|
127
|
+
private baseTitle?: string;
|
|
128
|
+
private logger?: PluginLogger;
|
|
129
|
+
private originalTitles = new Map<string, string>();
|
|
130
|
+
private blinkTimers = new Map<string, { timer: ReturnType<typeof setInterval>; finish: () => void }>();
|
|
131
|
+
|
|
132
|
+
constructor(options: WorkspaceBeaconOptions = {}) {
|
|
133
|
+
this.workspaceHandle = options.workspaceHandle ?? null;
|
|
134
|
+
this.daemonClient = options.daemonClient ?? null;
|
|
135
|
+
this.labelPrefix = options.labelPrefix ?? DEFAULT_BEACON_LABEL_PREFIX;
|
|
136
|
+
this.baseTitle = options.baseTitle;
|
|
137
|
+
this.logger = options.logger;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
setOptions(options: Partial<WorkspaceBeaconOptions>): void {
|
|
141
|
+
if ("workspaceHandle" in options) this.workspaceHandle = options.workspaceHandle ?? null;
|
|
142
|
+
if ("daemonClient" in options) this.daemonClient = options.daemonClient ?? null;
|
|
143
|
+
if (options.labelPrefix !== undefined) this.labelPrefix = options.labelPrefix;
|
|
144
|
+
if (options.baseTitle !== undefined) this.baseTitle = options.baseTitle;
|
|
145
|
+
if (options.logger !== undefined) this.logger = options.logger;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
get activeBlinks(): number {
|
|
149
|
+
return this.blinkTimers.size;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async set(options: BeaconSetOptions): Promise<BeaconSetResult> {
|
|
153
|
+
const result: BeaconSetResult = { labelApplied: false, titleApplied: false };
|
|
154
|
+
try {
|
|
155
|
+
const workspaceId = options.workspaceId;
|
|
156
|
+
const labelName = options.name ? resolveBeaconLabelName(options.name, this.labelPrefix) : undefined;
|
|
157
|
+
result.labelName = labelName;
|
|
158
|
+
|
|
159
|
+
if (workspaceId && labelName) {
|
|
160
|
+
result.labelApplied = await this.applyLabel(workspaceId, labelName, options.color);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const title = this.resolveTitle(options);
|
|
164
|
+
if (title !== undefined) {
|
|
165
|
+
result.titleApplied = await this.applyTitle(workspaceId, title);
|
|
166
|
+
if (result.titleApplied) result.title = title;
|
|
167
|
+
}
|
|
168
|
+
} catch (err) {
|
|
169
|
+
this.logger?.debug?.(
|
|
170
|
+
`WorkspaceBeacon.set failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
blink(options: BeaconBlinkOptions): BeaconBlinkHandle {
|
|
177
|
+
const intervalMs = Math.max(50, options.intervalMs ?? 3000);
|
|
178
|
+
const totalRounds = options.rounds ?? Number.POSITIVE_INFINITY;
|
|
179
|
+
const workspaceId = options.workspaceId ?? options.a.workspaceId ?? options.b.workspaceId;
|
|
180
|
+
const key = toTimerKey(workspaceId);
|
|
181
|
+
this.stopBlink(key);
|
|
182
|
+
let stopped = false;
|
|
183
|
+
let settled = false;
|
|
184
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
185
|
+
let resolveDone!: () => void;
|
|
186
|
+
const done = new Promise<void>((resolve) => {
|
|
187
|
+
resolveDone = resolve;
|
|
188
|
+
});
|
|
189
|
+
const finish = () => {
|
|
190
|
+
if (settled) return;
|
|
191
|
+
settled = true;
|
|
192
|
+
if (timer) clearInterval(timer);
|
|
193
|
+
const current = this.blinkTimers.get(key);
|
|
194
|
+
if (current?.finish === finish) this.blinkTimers.delete(key);
|
|
195
|
+
if (options.restoreOnDone) {
|
|
196
|
+
void this.clear({
|
|
197
|
+
workspaceId,
|
|
198
|
+
name: options.a.name ?? options.b.name,
|
|
199
|
+
restoreTitle: true,
|
|
200
|
+
}).catch((err) =>
|
|
201
|
+
this.logger?.debug?.(
|
|
202
|
+
`WorkspaceBeacon blink restore failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
resolveDone();
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const states: BeaconLabelState[] = [options.a, options.b];
|
|
211
|
+
const tick = () => {
|
|
212
|
+
if (stopped) {
|
|
213
|
+
finish();
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (count >= totalRounds) {
|
|
217
|
+
finish();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const state = states[count % 2];
|
|
221
|
+
count += 1;
|
|
222
|
+
void this.set({ ...state, workspaceId: state.workspaceId ?? workspaceId ?? "" }).catch((err) =>
|
|
223
|
+
this.logger?.debug?.(
|
|
224
|
+
`WorkspaceBeacon blink tick failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
225
|
+
),
|
|
226
|
+
);
|
|
227
|
+
if (count >= totalRounds) {
|
|
228
|
+
finish();
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
let count = 0;
|
|
232
|
+
void this.set({ ...states[0], workspaceId: states[0].workspaceId ?? workspaceId ?? "" }).catch(
|
|
233
|
+
(err) =>
|
|
234
|
+
this.logger?.debug?.(
|
|
235
|
+
`WorkspaceBeacon blink tick failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
236
|
+
),
|
|
237
|
+
);
|
|
238
|
+
count = 1;
|
|
239
|
+
if (count >= totalRounds) {
|
|
240
|
+
finish();
|
|
241
|
+
} else {
|
|
242
|
+
timer = setInterval(tick, intervalMs);
|
|
243
|
+
this.blinkTimers.set(key, { timer, finish });
|
|
244
|
+
}
|
|
245
|
+
} catch (err) {
|
|
246
|
+
this.logger?.debug?.(
|
|
247
|
+
`WorkspaceBeacon blink setup failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
248
|
+
);
|
|
249
|
+
finish();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
stop: () => {
|
|
254
|
+
stopped = true;
|
|
255
|
+
finish();
|
|
256
|
+
},
|
|
257
|
+
done,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async clear(options: BeaconClearOptions = {}): Promise<BeaconClearResult> {
|
|
262
|
+
const result: BeaconClearResult = { labelCleared: false, titleRestored: false };
|
|
263
|
+
try {
|
|
264
|
+
this.stopBlink(toTimerKey(options.workspaceId));
|
|
265
|
+
if (options.workspaceId && options.name) {
|
|
266
|
+
result.labelCleared = await this.detachLabel(
|
|
267
|
+
options.workspaceId,
|
|
268
|
+
resolveBeaconLabelName(options.name, this.labelPrefix),
|
|
269
|
+
);
|
|
270
|
+
} else if (options.workspaceId && !options.name) {
|
|
271
|
+
result.labelCleared = false;
|
|
272
|
+
}
|
|
273
|
+
if (options.restoreTitle !== false) {
|
|
274
|
+
result.titleRestored = await this.restoreTitle(options.workspaceId);
|
|
275
|
+
}
|
|
276
|
+
} catch (err) {
|
|
277
|
+
this.logger?.debug?.(
|
|
278
|
+
`WorkspaceBeacon.clear failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
stopAll(): void {
|
|
285
|
+
for (const entry of this.blinkTimers.values()) {
|
|
286
|
+
clearInterval(entry.timer);
|
|
287
|
+
try {
|
|
288
|
+
entry.finish();
|
|
289
|
+
} catch (err) {
|
|
290
|
+
this.logger?.debug?.(
|
|
291
|
+
`WorkspaceBeacon stopAll finish failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
this.blinkTimers.clear();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private resolveTitle(options: BeaconLabelState): string | undefined {
|
|
299
|
+
if (options.title !== undefined) return options.title;
|
|
300
|
+
if (options.titleSuffix === undefined) return undefined;
|
|
301
|
+
const base = this.originalTitles.get(options.workspaceId ?? "") ?? this.baseTitle ?? "";
|
|
302
|
+
return `${base}${options.titleSuffix}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private async applyLabel(workspaceId: string, labelName: string, color?: string): Promise<boolean> {
|
|
306
|
+
const client = this.daemonClient;
|
|
307
|
+
if (!client || !isFunction(client.setWorkspaceLabel)) return false;
|
|
308
|
+
const label: Record<string, string> = { name: labelName };
|
|
309
|
+
const normalized = normalizeBeaconColor(color);
|
|
310
|
+
if (normalized) label.color = normalized;
|
|
311
|
+
else if (color) label.color = color;
|
|
312
|
+
return safeInvoke(
|
|
313
|
+
() =>
|
|
314
|
+
(client.setWorkspaceLabel as (args: unknown) => unknown)({
|
|
315
|
+
workspaceId,
|
|
316
|
+
label,
|
|
317
|
+
assigned: true,
|
|
318
|
+
}),
|
|
319
|
+
this.logger,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private async detachLabel(workspaceId: string, labelName: string): Promise<boolean> {
|
|
324
|
+
const client = this.daemonClient as BeaconDaemonClient | null | undefined;
|
|
325
|
+
if (!client) return false;
|
|
326
|
+
try {
|
|
327
|
+
if (isFunction(client.setWorkspaceLabel)) {
|
|
328
|
+
return await safeInvoke(
|
|
329
|
+
() =>
|
|
330
|
+
(client.setWorkspaceLabel as (args: unknown) => unknown)({
|
|
331
|
+
workspaceId,
|
|
332
|
+
label: { name: labelName },
|
|
333
|
+
assigned: false,
|
|
334
|
+
}),
|
|
335
|
+
this.logger,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
if (isFunction(client.removeWorkspaceLabel)) {
|
|
339
|
+
return await safeInvoke(
|
|
340
|
+
() =>
|
|
341
|
+
(client.removeWorkspaceLabel as (args: unknown) => unknown)({
|
|
342
|
+
workspaceId,
|
|
343
|
+
name: labelName,
|
|
344
|
+
}),
|
|
345
|
+
this.logger,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
return false;
|
|
349
|
+
} catch (err) {
|
|
350
|
+
this.logger?.debug?.(
|
|
351
|
+
`WorkspaceBeacon detachLabel failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
352
|
+
);
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
setOriginalTitle(workspaceId: string, title: string): void {
|
|
358
|
+
this.originalTitles.set(workspaceId, title);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
hasOriginalTitle(workspaceId: string): boolean {
|
|
362
|
+
return this.originalTitles.has(workspaceId);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
getOriginalTitle(workspaceId: string): string | undefined {
|
|
366
|
+
return this.originalTitles.get(workspaceId);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private async applyTitle(workspaceId: string | undefined, title: string): Promise<boolean> {
|
|
370
|
+
const handle = this.workspaceHandle;
|
|
371
|
+
if (!handle || !isFunction(handle.setTitle)) return false;
|
|
372
|
+
const key = workspaceId ?? "";
|
|
373
|
+
if (!this.originalTitles.has(key)) {
|
|
374
|
+
if (this.baseTitle !== undefined) {
|
|
375
|
+
this.originalTitles.set(key, this.baseTitle);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return safeInvoke(() => (handle.setTitle as (t: string) => unknown)(title), this.logger);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private async restoreTitle(workspaceId: string | undefined): Promise<boolean> {
|
|
382
|
+
const handle = this.workspaceHandle;
|
|
383
|
+
if (!handle || !isFunction(handle.setTitle)) return false;
|
|
384
|
+
const key = workspaceId ?? "";
|
|
385
|
+
const original = this.originalTitles.get(key) ?? this.baseTitle;
|
|
386
|
+
if (original === undefined) return false;
|
|
387
|
+
const ok = await safeInvoke(
|
|
388
|
+
() => (handle.setTitle as (t: string) => unknown)(original),
|
|
389
|
+
this.logger,
|
|
390
|
+
);
|
|
391
|
+
if (ok) {
|
|
392
|
+
this.originalTitles.delete(key);
|
|
393
|
+
if (this.baseTitle === original) {
|
|
394
|
+
this.baseTitle = undefined;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return ok;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private stopBlink(key: string): void {
|
|
401
|
+
const entry = this.blinkTimers.get(key);
|
|
402
|
+
if (entry) {
|
|
403
|
+
clearInterval(entry.timer);
|
|
404
|
+
this.blinkTimers.delete(key);
|
|
405
|
+
try {
|
|
406
|
+
entry.finish();
|
|
407
|
+
} catch (err) {
|
|
408
|
+
this.logger?.debug?.(
|
|
409
|
+
`WorkspaceBeacon stopBlink finish failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function createWorkspaceBeacon(options: WorkspaceBeaconOptions = {}): WorkspaceBeacon {
|
|
417
|
+
return new WorkspaceBeacon(options);
|
|
418
|
+
}
|