ework-web 0.10.53 → 0.10.55

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-web",
3
- "version": "0.10.53",
3
+ "version": "0.10.55",
4
4
  "type": "module",
5
5
  "description": "ework-web — standalone multi-project issue tracker. Local SQLite-backed, no external API dependency. Bun + TypeScript + SSR HTML.",
6
6
  "license": "MIT",
package/src/config.ts CHANGED
@@ -82,6 +82,7 @@ export const configSchema = z.object({
82
82
  internalAuthHook: z.string().default(""),
83
83
  userAuthHook: z.string().default(""),
84
84
  loginPage: z.string().default(""),
85
+ issueActionsHook: z.string().default(""),
85
86
  // Default "provider/model" string passed to `opencode run --model <X>`.
86
87
  // Empty = let opencode pick per its own opencode.json + env. ework-daemon
87
88
  // pushes this (or the per-project override) on every spawn to defend
@@ -192,6 +193,7 @@ export async function loadConfig(): Promise<Config> {
192
193
  internalAuthHook: process.env.WORK_INTERNAL_AUTH_HOOK ?? "",
193
194
  userAuthHook: process.env.WORK_USER_AUTH_HOOK ?? "",
194
195
  loginPage: process.env.WORK_LOGIN_PAGE ?? "",
196
+ issueActionsHook: process.env.WORK_ISSUE_ACTIONS_HOOK ?? "",
195
197
  defaultModel: db.defaultModel ?? process.env.WORK_DEFAULT_MODEL,
196
198
  autowireActive: process.env.WORK_AUTOWIRE_ACTIVE !== "false",
197
199
  webhookMaxConcurrent: Number(process.env.WORK_WEBHOOK_MAX_CONCURRENT ?? "6"),
package/src/index.ts CHANGED
@@ -476,6 +476,8 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
476
476
  if (url.pathname === "/static/session.js") return staticAsset("session.js", "text/javascript; charset=utf-8", req);
477
477
  if (url.pathname === "/static/file.js") return staticAsset("file.js", "text/javascript; charset=utf-8", req);
478
478
  if (url.pathname === "/static/tts.js") return staticAsset("tts.js", "text/javascript; charset=utf-8", req);
479
+ if (url.pathname === "/static/issue-actions.js") return staticAsset("issue-actions.js", "text/javascript; charset=utf-8", req);
480
+ if (url.pathname === "/static/row-nav.js") return staticAsset("row-nav.js", "text/javascript; charset=utf-8", req);
479
481
  if (url.pathname === "/static/highlight.css") {
480
482
  return new Response(hlCss, {
481
483
  headers: { "content-type": "text/css; charset=utf-8", "cache-control": "no-cache", ...SEC_HEADERS },
@@ -0,0 +1,73 @@
1
+ export interface IssueActionContext {
2
+ owner: string;
3
+ repo: string;
4
+ issueNumber: number;
5
+ state: string;
6
+ aiStatus: string;
7
+ viewerLogin: string;
8
+ viewerIsAdmin: boolean;
9
+ labels: { id: number; name: string; color: string }[];
10
+ }
11
+
12
+ export interface IssueAction {
13
+ id: string;
14
+ label: string;
15
+ title?: string;
16
+ method?: "POST" | "GET";
17
+ href: string;
18
+ confirm?: string;
19
+ reloadOnOk?: boolean;
20
+ className?: string;
21
+ }
22
+
23
+ export interface IssueActionsProvider {
24
+ issueActions(ctx: IssueActionContext): Promise<IssueAction[]> | IssueAction[];
25
+ statusBadges?: Record<string, { cls: string; label: string }>;
26
+ }
27
+
28
+ const hookCache = new Map<string, IssueActionsProvider | null>();
29
+
30
+ async function loadHook(hookPath: string): Promise<IssueActionsProvider | null> {
31
+ const cached = hookCache.get(hookPath);
32
+ if (cached !== undefined) return cached;
33
+
34
+ try {
35
+ const path = await import("node:path");
36
+ const { pathToFileURL } = await import("node:url");
37
+ const abs = path.isAbsolute(hookPath) ? hookPath : path.resolve(process.cwd(), hookPath);
38
+ const mod = await import(pathToFileURL(abs).href);
39
+ const provider: IssueActionsProvider = mod.default ?? mod;
40
+ if (!provider || typeof provider.issueActions !== "function") {
41
+ console.warn(`[issue-actions-hook] ${hookPath}: must export an object with issueActions()`);
42
+ hookCache.set(hookPath, null);
43
+ return null;
44
+ }
45
+ hookCache.set(hookPath, provider);
46
+ return provider;
47
+ } catch (e) {
48
+ console.warn(`[issue-actions-hook] failed to load ${hookPath}:`, e);
49
+ hookCache.set(hookPath, null);
50
+ return null;
51
+ }
52
+ }
53
+
54
+ export async function runIssueActionsHook(
55
+ hookPath: string,
56
+ ctx: IssueActionContext,
57
+ ): Promise<{ actions: IssueAction[]; statusBadges?: Record<string, { cls: string; label: string }> }> {
58
+ if (!hookPath) return { actions: [] };
59
+ const provider = await loadHook(hookPath);
60
+ if (!provider) return { actions: [] };
61
+ try {
62
+ const actions = await provider.issueActions(ctx);
63
+ if (!Array.isArray(actions)) return { actions: [], statusBadges: provider.statusBadges };
64
+ return { actions, statusBadges: provider.statusBadges };
65
+ } catch (e) {
66
+ console.warn(`[issue-actions-hook] ${hookPath}: issueActions() threw:`, e);
67
+ return { actions: [] };
68
+ }
69
+ }
70
+
71
+ export function clearIssueActionsHookCache(): void {
72
+ hookCache.clear();
73
+ }
@@ -1,4 +1,5 @@
1
1
  import { BUILD_ID } from "../build";
2
+ import type { IssueAction } from "../issue-actions-hook";
2
3
  export interface LayoutProps {
3
4
  title: string;
4
5
  issueTitle: string;
@@ -17,6 +18,10 @@ export interface LayoutProps {
17
18
  labels?: { id: number; name: string; color: string }[];
18
19
  canEditLabels?: boolean;
19
20
  aiStatus?: string;
21
+ viewerLogin?: string;
22
+ viewerIsAdmin?: boolean;
23
+ customActions?: IssueAction[];
24
+ extraStatusBadges?: Record<string, { cls: string; label: string }>;
20
25
  }
21
26
 
22
27
  export const THEME_CSS = `
@@ -167,18 +172,21 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
167
172
  dispatch_off: { cls: "ai-dispatch-off", label: "🔕 不接单" },
168
173
  completed: { cls: "ai-completed", label: "✓ 已完成" },
169
174
  failed: { cls: "ai-failed", label: "✗ 失败" },
175
+ ...props.extraStatusBadges,
170
176
  };
171
177
  const m = map[s];
172
178
  if (!m) return "";
173
179
  return `<span class="ai-badge ${m.cls}">${m.label}</span>`;
174
180
  })();
175
- const haltBtnHtml = props.writesEnabled !== false && props.aiStatus !== "halted"
176
- ? `<button type="button" id="haltBtn" class="halt-btn" title="停止 AI 处理">⏹ 停止</button>`
181
+ const haltBtnHtml = props.writesEnabled !== false
182
+ ? props.aiStatus === "halted"
183
+ ? `<button type="button" class="halt-btn resume-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/resume" data-action-confirm="确认恢复 AI 处理?" title="恢复 AI 处理">▶ 恢复</button>`
184
+ : `<button type="button" class="halt-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/halt" data-action-confirm="确认停止 AI 处理?" title="停止 AI 处理">⏹ 停止</button>`
177
185
  : "";
178
186
  const dispatchBtnHtml = props.writesEnabled !== false
179
187
  ? props.aiStatus === "dispatch_off"
180
- ? `<button type="button" id="dispatchBtn" class="dispatch-btn" title="允许自动接单">🔔 接单</button>`
181
- : `<button type="button" id="dispatchBtn" class="dispatch-btn" title="设为不自动接单">🔕 不接单</button>`
188
+ ? `<button type="button" class="dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-on" title="允许自动接单">🔔 接单</button>`
189
+ : `<button type="button" class="dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-off" data-action-confirm="设为不自动接单?" title="设为不自动接单">🔕 不接单</button>`
182
190
  : "";
183
191
  return `<!doctype html>
184
192
  <html lang="zh">
@@ -201,7 +209,14 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
201
209
  <h1>${escapeHtml(props.issueTitle)}</h1>
202
210
  <div class="meta-status">
203
211
  <span class="state-badge ${stateClass}">${stateLabel}</span>
204
- ${aiBadgeHtml}${haltBtnHtml}${dispatchBtnHtml}
212
+ ${aiBadgeHtml}${haltBtnHtml}${dispatchBtnHtml}${(props.customActions ?? []).map((a) => {
213
+ const attrs = [`data-action-href="${escapeAttr(a.href)}"`];
214
+ if (a.method && a.method !== "POST") attrs.push(`data-action-method="${escapeAttr(a.method)}"`);
215
+ if (a.confirm) attrs.push(`data-action-confirm="${escapeAttr(a.confirm)}"`);
216
+ if (a.reloadOnOk === false) attrs.push(`data-action-reload="false"`);
217
+ const cls = a.className ? `${escapeAttr(a.className)}` : "custom-action-btn";
218
+ return `<button type="button" class="${cls}" ${attrs.join(" ")}${a.title ? ` title="${escapeAttr(a.title)}"` : ""}>${escapeHtml(a.label)}</button>`;
219
+ }).join("")}
205
220
  ${labelsHtml}${labelPickerBtn}
206
221
  <span class="count" id="count">…</span>
207
222
  ${props.upstreamWebUrl ? `<a class="upstream-link" href="${escapeAttr(props.upstreamWebUrl)}" target="_blank" rel="noopener noreferrer" title="跳转到上游仓库">🔗 查看上游</a>` : ""}
@@ -233,32 +248,7 @@ ${props.writesEnabled !== false
233
248
  <script src="/static/app.js?v=${BUILD_ID}" defer></script>
234
249
  ${props.canEditLabels ? `<dialog id="labelDlg"><h3>标签</h3><div class="lp-list" id="lpList"></div><div class="lp-empty hidden" id="lpEmpty">该项目还没有标签。先到设置页创建。</div></dialog>
235
250
  <script src="/static/label-picker.js?v=${BUILD_ID}" defer></script>` : ""}
236
- ${haltBtnHtml ? `<script>
237
- (function(){
238
- var btn=document.getElementById("haltBtn");
239
- if(!btn)return;
240
- btn.addEventListener("click",function(){
241
- if(!confirm("确认停止 AI 处理?"))return;
242
- btn.disabled=true;btn.textContent="⏳ 停止中…";
243
- fetch(window.location.pathname+"/halt",{method:"POST"}).then(function(r){return r.json()}).then(function(d){
244
- if(d.ok){location.reload()}else{alert(d.error||"操作失败");btn.disabled=false;btn.textContent="⏹ 停止"}
245
- }).catch(function(e){alert("网络错误: "+e);btn.disabled=false;btn.textContent="⏹ 停止"})
246
- });
247
- })();
248
- </script>` : ""}
249
- ${dispatchBtnHtml ? `<script>
250
- (function(){
251
- var btn=document.getElementById("dispatchBtn");
252
- if(!btn)return;
253
- btn.addEventListener("click",function(){
254
- var off=btn.textContent.indexOf("不接单")>=0;
255
- btn.disabled=true;btn.textContent="⏳ …";
256
- fetch(window.location.pathname+"/"+(off?"dispatch-off":"dispatch-on"),{method:"POST"}).then(function(r){return r.json()}).then(function(d){
257
- if(d.ok){location.reload()}else{alert(d.error||"操作失败");btn.disabled=false;btn.textContent=off?"🔕 不接单":"🔔 接单"}
258
- }).catch(function(e){alert("网络错误: "+e);btn.disabled=false;btn.textContent=off?"🔕 不接单":"🔔 接单"})
259
- });
260
- })();
261
- </script>` : ""}
251
+ <script src="/static/issue-actions.js?v=${BUILD_ID}" defer></script>
262
252
  </body>
263
253
  </html>`;
264
254
  }
@@ -0,0 +1,38 @@
1
+ // Universal delegated click handler for issue action buttons.
2
+ // Buttons use data-* attributes so no inline JS is needed (CSP-safe).
3
+ // Supported attributes:
4
+ // data-action-href="/owner/repo/issues/N/halt" — POST target
5
+ // data-action-method="POST" — default POST
6
+ // data-action-confirm="确认停止?" — if set, confirm() first
7
+ // data-action-reload="true" — default true, reload on success
8
+ (function () {
9
+ document.addEventListener("click", function (e) {
10
+ var btn = e.target.closest("[data-action-href]");
11
+ if (!btn) return;
12
+ e.preventDefault();
13
+ var href = btn.getAttribute("data-action-href");
14
+ var method = btn.getAttribute("data-action-method") || "POST";
15
+ var confirmMsg = btn.getAttribute("data-action-confirm");
16
+ var reload = btn.getAttribute("data-action-reload") !== "false";
17
+ if (confirmMsg && !confirm(confirmMsg)) return;
18
+ var orig = btn.textContent;
19
+ btn.disabled = true;
20
+ btn.textContent = "⏳ …";
21
+ fetch(href, { method: method })
22
+ .then(function (r) { return r.json(); })
23
+ .then(function (d) {
24
+ if (d.ok) {
25
+ if (reload) location.reload();
26
+ } else {
27
+ alert(d.error || "操作失败");
28
+ btn.disabled = false;
29
+ btn.textContent = orig;
30
+ }
31
+ })
32
+ .catch(function (err) {
33
+ alert("网络错误: " + err);
34
+ btn.disabled = false;
35
+ btn.textContent = orig;
36
+ });
37
+ });
38
+ })();
@@ -0,0 +1,13 @@
1
+ // Delegated click handler for label chips inside <a class="row"> elements.
2
+ // Labels are <span class="issue-label" data-href="..."> nested inside the row <a>.
3
+ // Without interception, clicking a label would navigate to the issue (the <a> default).
4
+ // This handler stops propagation and navigates to the label filter URL instead.
5
+ (function () {
6
+ document.addEventListener("click", function (e) {
7
+ var label = e.target.closest(".issue-label[data-href]");
8
+ if (!label) return;
9
+ e.preventDefault();
10
+ e.stopPropagation();
11
+ location.href = label.getAttribute("data-href");
12
+ });
13
+ })();
@@ -1,4 +1,5 @@
1
1
  import { THEME_CSS, escapeHtml, escapeAttr, containsCI, highlightAll, tabNavHTML, aiStatusBadge } from "../render/layout";
2
+ import { BUILD_ID } from "../build";
2
3
  import { getProject, listIssues, listLabelsForIssues, type IssueWithMeta, type LabelRow } from "../store";
3
4
  import { relTime } from "../render/components";
4
5
 
@@ -23,10 +24,10 @@ function issueRow(
23
24
  ? `<span class="row-labels">${labels.map((l) => labelChip(l, owner, repo, state)).join("")}</span>`
24
25
  : "";
25
26
  const aiBadge = aiStatusBadge(it.ai_status);
26
- return `<div class="row" data-href="${escapeAttr(href)}">
27
+ return `<a class="row" href="${escapeAttr(href)}">
27
28
  <div class="row-title">${title}${chips}</div>
28
29
  <div class="row-meta">#${it.number} · 💬 ${it.comment_count} · ${relTime(it.updated_at)}${aiBadge ? ` · ${aiBadge}` : ""}</div>
29
- </div>`;
30
+ </a>`;
30
31
  }
31
32
 
32
33
  export async function buildIssueList(
@@ -75,7 +76,7 @@ export async function buildIssueList(
75
76
  .tabs-sub a{padding:.3rem .8rem;border-radius:6px;font-size:13px;color:var(--text-muted)}
76
77
  .tabs-sub a.active{background:var(--bg-muted);color:var(--text);font-weight:600}
77
78
  .new-btn{margin-left:auto;background:var(--green);color:#fff;padding:.3rem .8rem;border-radius:6px;font-size:13px;font-weight:600}
78
- .row{display:block;padding:.6rem .2rem;border-bottom:1px solid var(--border);color:var(--text)}
79
+ .row{display:block;padding:.6rem .2rem;border-bottom:1px solid var(--border);color:var(--text);text-decoration:none}
79
80
  .row:hover{text-decoration:none;background:var(--bg-muted)}
80
81
  .row-title{font-weight:500;overflow-wrap:anywhere}
81
82
  .row-meta{color:var(--text-muted);font-size:12px;margin-top:.2rem}
@@ -109,7 +110,7 @@ ${tabNavHTML("issues", viewer ? { login: viewer.login, is_admin: viewer.is_admin
109
110
  </div>
110
111
  ${labelFilterHint}
111
112
  <div class="rows">${rows}</div>
112
- <script>document.addEventListener("click",function(e){var l=e.target.closest(".issue-label");if(l){e.preventDefault();e.stopPropagation();location.href=l.dataset.href;return}var r=e.target.closest(".row[data-href]");if(r){location.href=r.dataset.href}})</script>
113
+ <script src="/static/row-nav.js?v=${BUILD_ID}" defer></script>
113
114
  </main>
114
115
  </body></html>`;
115
116
  }
@@ -2,6 +2,7 @@ import type { Config } from "../config";
2
2
  import { classifyActor, renderCommentCard, type CommentView } from "../render/components";
3
3
  import { renderMarkdown } from "../render/markdown";
4
4
  import { renderLayout, escapeHtml } from "../render/layout";
5
+ import { runIssueActionsHook, type IssueActionContext, type IssueAction } from "../issue-actions-hook";
5
6
  import { hydrateReactions } from "../reactions";
6
7
  import {
7
8
  StoreError,
@@ -12,6 +13,7 @@ import {
12
13
  listCommentsSince,
13
14
  getDefaultUpstreamUrl,
14
15
  listLabelsForIssue,
16
+ getUserByLogin,
15
17
  type CommentRow,
16
18
  type IssueWithMeta,
17
19
  } from "../store";
@@ -112,6 +114,22 @@ export async function buildIssueThread(
112
114
  return webUrlFromClone(clone);
113
115
  })();
114
116
  const labels = await listLabelsForIssue(issue.id);
117
+ const viewerUser = viewerLogin ? await getUserByLogin(viewerLogin) : null;
118
+ const viewerIsAdmin = !!viewerUser?.is_admin;
119
+
120
+ let customActions: IssueAction[] = [];
121
+ let extraStatusBadges: Record<string, { cls: string; label: string }> | undefined;
122
+ if (cfg.issueActionsHook && viewerLogin) {
123
+ const ctx: IssueActionContext = {
124
+ owner, repo, issueNumber: number,
125
+ state: issue.state, aiStatus: issue.ai_status ?? "",
126
+ viewerLogin, viewerIsAdmin,
127
+ labels: labels.map((l) => ({ id: l.id, name: l.name, color: l.color })),
128
+ };
129
+ const result = await runIssueActionsHook(cfg.issueActionsHook, ctx);
130
+ customActions = result.actions;
131
+ extraStatusBadges = result.statusBadges;
132
+ }
115
133
 
116
134
  const html = renderLayout(
117
135
  {
@@ -131,6 +149,10 @@ export async function buildIssueThread(
131
149
  labels: labels.map((l) => ({ id: l.id, name: l.name, color: l.color })),
132
150
  canEditLabels: cfg.writesEnabled !== false,
133
151
  aiStatus: issue.ai_status ?? "",
152
+ viewerLogin,
153
+ viewerIsAdmin,
154
+ customActions,
155
+ extraStatusBadges,
134
156
  },
135
157
  safeJsonEmbed(payload),
136
158
  displayViews.map((v) => renderCommentCard(v, cfg)).join("")
@@ -1,4 +1,5 @@
1
1
  import { THEME_CSS, escapeHtml, escapeAttr, containsCI, highlightAll, tabNavHTML, aiStatusBadge } from "../render/layout";
2
+ import { BUILD_ID } from "../build";
2
3
  import { listAllIssues, listLabelsForIssues, type IssueWithMeta, type LabelRow } from "../store";
3
4
  import { relTime } from "../render/components";
4
5
 
@@ -17,10 +18,10 @@ function issueRow(it: IssueWithMeta, q: string, state: string, labels: LabelRow[
17
18
  ? `<span class="row-labels">${labels.map((l) => labelChip(l, it.project_owner, it.project_name, state)).join("")}</span>`
18
19
  : "";
19
20
  const aiBadge = aiStatusBadge(it.ai_status);
20
- return `<div class="row" data-href="${escapeAttr(href)}">
21
+ return `<a class="row" href="${escapeAttr(href)}">
21
22
  <div class="row-title">${title}${chips}</div>
22
23
  <div class="row-meta">${projectStr} · #${it.number} · 💬 ${it.comment_count} · ${relTime(it.updated_at)}${aiBadge ? ` · ${aiBadge}` : ""}</div>
23
- </div>`;
24
+ </a>`;
24
25
  }
25
26
 
26
27
  export async function buildIssuesFeed(
@@ -60,7 +61,7 @@ export async function buildIssuesFeed(
60
61
  .tabs-sub{display:flex;gap:.3rem;margin-bottom:.6rem}
61
62
  .tabs-sub a{padding:.3rem .8rem;border-radius:6px;font-size:13px;color:var(--text-muted)}
62
63
  .tabs-sub a.active{background:var(--bg-muted);color:var(--text);font-weight:600}
63
- .row{display:block;padding:.6rem .2rem;border-bottom:1px solid var(--border);color:var(--text)}
64
+ .row{display:block;padding:.6rem .2rem;border-bottom:1px solid var(--border);color:var(--text);text-decoration:none}
64
65
  .row:hover{text-decoration:none;background:var(--bg-muted)}
65
66
  .row-title{font-weight:500;overflow-wrap:anywhere}
66
67
  .row-meta{color:var(--text-muted);font-size:12px;margin-top:.2rem}
@@ -87,7 +88,7 @@ ${tabNavHTML("issues", viewer ? { login: viewer.login, is_admin: viewer.is_admin
87
88
  </div>
88
89
  ${labelFilterHint}
89
90
  <div class="rows">${rows}</div>
90
- <script>document.addEventListener("click",function(e){var l=e.target.closest(".issue-label");if(l){e.preventDefault();e.stopPropagation();location.href=l.dataset.href;return}var r=e.target.closest(".row[data-href]");if(r){location.href=r.dataset.href}})</script>
91
+ <script src="/static/row-nav.js?v=${BUILD_ID}" defer></script>
91
92
  </main>
92
93
  </body></html>`;
93
94
  }
package/src/webhooks.ts CHANGED
@@ -27,6 +27,8 @@ import {
27
27
  ensureUser,
28
28
  resolveModel,
29
29
  listLabelsForIssue,
30
+ getUserByLogin,
31
+ canWriteProject,
30
32
  type IssueRow,
31
33
  type ProjectRow,
32
34
  type CommentRow,
@@ -692,6 +694,15 @@ export async function emitCommentEvent(
692
694
  if (cfg["dispatchEnabled"] === "false" || cfg[`dispatchOff:${scopeKey}`] === "1" || (issue as IssueRow).ai_status === "dispatch_off") {
693
695
  log.info(`webhook: dispatch disabled but waking via comment_created (author=${comment.author}) for ${scopeKey}#${issue.number}`);
694
696
  }
697
+ const authorUser = await getUserByLogin(comment.author);
698
+ if (authorUser && authorUser.kind !== "human") {
699
+ log.info(`webhook: comment wake denied (author=${comment.author} kind=${authorUser.kind}) for ${scopeKey}#${issue.number}`);
700
+ return;
701
+ }
702
+ if (authorUser && !(await canWriteProject(projectId, { login: comment.author, is_admin: authorUser.is_admin }))) {
703
+ log.info(`webhook: comment wake denied (author=${comment.author} not project member) for ${scopeKey}#${issue.number}`);
704
+ return;
705
+ }
695
706
  const commentCount = await countCommentsSafe(issueId);
696
707
  const globalDefault = (await loadConfig()).defaultModel;
697
708
  const model = resolveModel(project.model, globalDefault);