webmux 0.17.0 → 0.19.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/README.md +1 -1
- package/backend/dist/server.js +785 -201
- package/bin/webmux.js +224 -67
- package/frontend/dist/assets/index-CKvK6kRL.js +148 -0
- package/frontend/dist/assets/index-N_4ol4EP.css +32 -0
- package/frontend/dist/icon.svg +1 -0
- package/frontend/dist/index.html +5 -7
- package/frontend/dist/manifest.webmanifest +16 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-CMdCoXme.js +0 -148
- package/frontend/dist/assets/index-nt-8CG4j.css +0 -32
package/backend/dist/server.js
CHANGED
|
@@ -6905,6 +6905,7 @@ var require_public_api = __commonJS((exports) => {
|
|
|
6905
6905
|
});
|
|
6906
6906
|
|
|
6907
6907
|
// backend/src/server.ts
|
|
6908
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
6908
6909
|
import { join as join6, resolve as resolve6 } from "path";
|
|
6909
6910
|
import { mkdirSync } from "fs";
|
|
6910
6911
|
import { networkInterfaces } from "os";
|
|
@@ -6997,13 +6998,13 @@ function killTmuxSession(name) {
|
|
|
6997
6998
|
}
|
|
6998
6999
|
}
|
|
6999
7000
|
}
|
|
7000
|
-
async function attach(
|
|
7001
|
-
log.debug(`[term] attach(${
|
|
7002
|
-
if (sessions.has(
|
|
7003
|
-
await detach(
|
|
7001
|
+
async function attach(attachId, target, cols, rows, initialPane) {
|
|
7002
|
+
log.debug(`[term] attach(${attachId}) cols=${cols} rows=${rows} existing=${sessions.has(attachId)}`);
|
|
7003
|
+
if (sessions.has(attachId)) {
|
|
7004
|
+
await detach(attachId);
|
|
7004
7005
|
}
|
|
7005
7006
|
const gName = groupedName();
|
|
7006
|
-
log.debug(`[term] attach(${
|
|
7007
|
+
log.debug(`[term] attach(${attachId}) ownerSession=${target.ownerSessionName} gName=${gName} window=${target.windowName}`);
|
|
7007
7008
|
killTmuxSession(gName);
|
|
7008
7009
|
const cmd = buildAttachCmd({
|
|
7009
7010
|
gName,
|
|
@@ -7030,8 +7031,8 @@ async function attach(worktreeId, target, cols, rows, initialPane) {
|
|
|
7030
7031
|
onExit: null,
|
|
7031
7032
|
cancelled: false
|
|
7032
7033
|
};
|
|
7033
|
-
sessions.set(
|
|
7034
|
-
log.debug(`[term] attach(${
|
|
7034
|
+
sessions.set(attachId, session);
|
|
7035
|
+
log.debug(`[term] attach(${attachId}) spawned pid=${proc.pid}`);
|
|
7035
7036
|
(async () => {
|
|
7036
7037
|
const reader = proc.stdout.getReader();
|
|
7037
7038
|
try {
|
|
@@ -7052,7 +7053,7 @@ async function attach(worktreeId, target, cols, rows, initialPane) {
|
|
|
7052
7053
|
}
|
|
7053
7054
|
} catch (err) {
|
|
7054
7055
|
if (!session.cancelled) {
|
|
7055
|
-
log.error(`[term] stdout reader error(${
|
|
7056
|
+
log.error(`[term] stdout reader error(${attachId})`, err);
|
|
7056
7057
|
}
|
|
7057
7058
|
}
|
|
7058
7059
|
})();
|
|
@@ -7063,55 +7064,55 @@ async function attach(worktreeId, target, cols, rows, initialPane) {
|
|
|
7063
7064
|
const { done, value } = await reader.read();
|
|
7064
7065
|
if (done)
|
|
7065
7066
|
break;
|
|
7066
|
-
log.debug(`[term] stderr(${
|
|
7067
|
+
log.debug(`[term] stderr(${attachId}): ${textDecoder.decode(value).trimEnd()}`);
|
|
7067
7068
|
}
|
|
7068
7069
|
} catch {}
|
|
7069
7070
|
})();
|
|
7070
7071
|
proc.exited.then((exitCode) => {
|
|
7071
|
-
log.debug(`[term] proc exited(${
|
|
7072
|
-
if (sessions.get(
|
|
7072
|
+
log.debug(`[term] proc exited(${attachId}) pid=${proc.pid} code=${exitCode}`);
|
|
7073
|
+
if (sessions.get(attachId) === session) {
|
|
7073
7074
|
session.onExit?.(exitCode);
|
|
7074
|
-
sessions.delete(
|
|
7075
|
+
sessions.delete(attachId);
|
|
7075
7076
|
} else {
|
|
7076
|
-
log.debug(`[term] proc exited(${
|
|
7077
|
+
log.debug(`[term] proc exited(${attachId}) stale session, skipping cleanup`);
|
|
7077
7078
|
}
|
|
7078
7079
|
killTmuxSession(gName);
|
|
7079
7080
|
});
|
|
7080
7081
|
}
|
|
7081
|
-
async function detach(
|
|
7082
|
-
const session = sessions.get(
|
|
7082
|
+
async function detach(attachId) {
|
|
7083
|
+
const session = sessions.get(attachId);
|
|
7083
7084
|
if (!session) {
|
|
7084
|
-
log.debug(`[term] detach(${
|
|
7085
|
+
log.debug(`[term] detach(${attachId}) no session found`);
|
|
7085
7086
|
return;
|
|
7086
7087
|
}
|
|
7087
|
-
log.debug(`[term] detach(${
|
|
7088
|
+
log.debug(`[term] detach(${attachId}) killing pid=${session.proc.pid} tmux=${session.groupedSessionName}`);
|
|
7088
7089
|
session.cancelled = true;
|
|
7089
7090
|
session.proc.kill();
|
|
7090
|
-
sessions.delete(
|
|
7091
|
+
sessions.delete(attachId);
|
|
7091
7092
|
killTmuxSession(session.groupedSessionName);
|
|
7092
7093
|
}
|
|
7093
|
-
function write(
|
|
7094
|
-
const session = sessions.get(
|
|
7094
|
+
function write(attachId, data) {
|
|
7095
|
+
const session = sessions.get(attachId);
|
|
7095
7096
|
if (!session) {
|
|
7096
|
-
log.warn(`[term] write(${
|
|
7097
|
+
log.warn(`[term] write(${attachId}) NO SESSION - input dropped (${data.length} bytes)`);
|
|
7097
7098
|
return;
|
|
7098
7099
|
}
|
|
7099
7100
|
try {
|
|
7100
7101
|
session.proc.stdin.write(textEncoder.encode(data));
|
|
7101
7102
|
session.proc.stdin.flush();
|
|
7102
7103
|
} catch (err) {
|
|
7103
|
-
log.error(`[term] write(${
|
|
7104
|
+
log.error(`[term] write(${attachId}) stdin closed`, err);
|
|
7104
7105
|
}
|
|
7105
7106
|
}
|
|
7106
|
-
async function sendKeys(
|
|
7107
|
-
const session = sessions.get(
|
|
7107
|
+
async function sendKeys(attachId, hexBytes) {
|
|
7108
|
+
const session = sessions.get(attachId);
|
|
7108
7109
|
if (!session)
|
|
7109
7110
|
return;
|
|
7110
7111
|
const windowTarget = `${session.groupedSessionName}:${session.windowName}`;
|
|
7111
7112
|
await tmuxExec(["tmux", "send-keys", "-t", windowTarget, "-H", ...hexBytes]);
|
|
7112
7113
|
}
|
|
7113
|
-
async function resize(
|
|
7114
|
-
const session = sessions.get(
|
|
7114
|
+
async function resize(attachId, cols, rows) {
|
|
7115
|
+
const session = sessions.get(attachId);
|
|
7115
7116
|
if (!session)
|
|
7116
7117
|
return;
|
|
7117
7118
|
const windowTarget = `${session.groupedSessionName}:${session.windowName}`;
|
|
@@ -7119,32 +7120,32 @@ async function resize(worktreeId, cols, rows) {
|
|
|
7119
7120
|
if (result.exitCode !== 0)
|
|
7120
7121
|
log.warn(`[term] resize failed: ${result.stderr}`);
|
|
7121
7122
|
}
|
|
7122
|
-
function getScrollback(
|
|
7123
|
-
return sessions.get(
|
|
7123
|
+
function getScrollback(attachId) {
|
|
7124
|
+
return sessions.get(attachId)?.scrollback.join("") ?? "";
|
|
7124
7125
|
}
|
|
7125
|
-
function setCallbacks(
|
|
7126
|
-
const session = sessions.get(
|
|
7126
|
+
function setCallbacks(attachId, onData, onExit) {
|
|
7127
|
+
const session = sessions.get(attachId);
|
|
7127
7128
|
if (session) {
|
|
7128
7129
|
session.onData = onData;
|
|
7129
7130
|
session.onExit = onExit;
|
|
7130
7131
|
}
|
|
7131
7132
|
}
|
|
7132
|
-
async function selectPane(
|
|
7133
|
-
const session = sessions.get(
|
|
7133
|
+
async function selectPane(attachId, paneIndex) {
|
|
7134
|
+
const session = sessions.get(attachId);
|
|
7134
7135
|
if (!session) {
|
|
7135
|
-
log.debug(`[term] selectPane(${
|
|
7136
|
+
log.debug(`[term] selectPane(${attachId}) no session found`);
|
|
7136
7137
|
return;
|
|
7137
7138
|
}
|
|
7138
7139
|
const target = `${session.groupedSessionName}:${session.windowName}.${paneIndex}`;
|
|
7139
|
-
log.debug(`[term] selectPane(${
|
|
7140
|
+
log.debug(`[term] selectPane(${attachId}) pane=${paneIndex} target=${target}`);
|
|
7140
7141
|
const [r1, r2] = await Promise.all([
|
|
7141
7142
|
tmuxExec(["tmux", "select-pane", "-t", target]),
|
|
7142
7143
|
tmuxExec(["tmux", "resize-pane", "-Z", "-t", target])
|
|
7143
7144
|
]);
|
|
7144
|
-
log.debug(`[term] selectPane(${
|
|
7145
|
+
log.debug(`[term] selectPane(${attachId}) select=${r1.exitCode} zoom=${r2.exitCode}`);
|
|
7145
7146
|
}
|
|
7146
|
-
function clearCallbacks(
|
|
7147
|
-
const session = sessions.get(
|
|
7147
|
+
function clearCallbacks(attachId) {
|
|
7148
|
+
const session = sessions.get(attachId);
|
|
7148
7149
|
if (session) {
|
|
7149
7150
|
session.onData = null;
|
|
7150
7151
|
session.onExit = null;
|
|
@@ -7268,7 +7269,7 @@ var DEFAULT_CONFIG = {
|
|
|
7268
7269
|
startupEnvs: {},
|
|
7269
7270
|
integrations: {
|
|
7270
7271
|
github: { linkedRepos: [] },
|
|
7271
|
-
linear: { enabled: true }
|
|
7272
|
+
linear: { enabled: true, autoCreateWorktrees: false, createTicketOption: false }
|
|
7272
7273
|
},
|
|
7273
7274
|
lifecycleHooks: {},
|
|
7274
7275
|
autoName: null
|
|
@@ -7469,7 +7470,10 @@ function parseProjectConfig(parsed) {
|
|
|
7469
7470
|
linkedRepos: isRecord(parsed.integrations) && isRecord(parsed.integrations.github) ? parseLinkedRepos(parsed.integrations.github.linkedRepos) : isRecord(parsed.integrations) && Array.isArray(parsed.integrations.github) ? parseLinkedRepos(parsed.integrations.github) : []
|
|
7470
7471
|
},
|
|
7471
7472
|
linear: {
|
|
7472
|
-
enabled: isRecord(parsed.integrations) && isRecord(parsed.integrations.linear) && typeof parsed.integrations.linear.enabled === "boolean" ? parsed.integrations.linear.enabled : DEFAULT_CONFIG.integrations.linear.enabled
|
|
7473
|
+
enabled: isRecord(parsed.integrations) && isRecord(parsed.integrations.linear) && typeof parsed.integrations.linear.enabled === "boolean" ? parsed.integrations.linear.enabled : DEFAULT_CONFIG.integrations.linear.enabled,
|
|
7474
|
+
autoCreateWorktrees: isRecord(parsed.integrations) && isRecord(parsed.integrations.linear) && typeof parsed.integrations.linear.autoCreateWorktrees === "boolean" ? parsed.integrations.linear.autoCreateWorktrees : DEFAULT_CONFIG.integrations.linear.autoCreateWorktrees,
|
|
7475
|
+
createTicketOption: isRecord(parsed.integrations) && isRecord(parsed.integrations.linear) && typeof parsed.integrations.linear.createTicketOption === "boolean" ? parsed.integrations.linear.createTicketOption : DEFAULT_CONFIG.integrations.linear.createTicketOption,
|
|
7476
|
+
...isRecord(parsed.integrations) && isRecord(parsed.integrations.linear) && typeof parsed.integrations.linear.teamId === "string" && parsed.integrations.linear.teamId.trim() ? { teamId: parsed.integrations.linear.teamId.trim() } : {}
|
|
7473
7477
|
}
|
|
7474
7478
|
},
|
|
7475
7479
|
lifecycleHooks: parseLifecycleHooks(parsed.lifecycleHooks),
|
|
@@ -7479,21 +7483,39 @@ function parseProjectConfig(parsed) {
|
|
|
7479
7483
|
function defaultConfig() {
|
|
7480
7484
|
return parseProjectConfig({});
|
|
7481
7485
|
}
|
|
7486
|
+
function parseLocalLinearOverlay(parsed) {
|
|
7487
|
+
if (!isRecord(parsed.integrations))
|
|
7488
|
+
return null;
|
|
7489
|
+
const linear = parsed.integrations.linear;
|
|
7490
|
+
if (!isRecord(linear))
|
|
7491
|
+
return null;
|
|
7492
|
+
const overlay = {};
|
|
7493
|
+
if (typeof linear.enabled === "boolean")
|
|
7494
|
+
overlay.enabled = linear.enabled;
|
|
7495
|
+
if (typeof linear.autoCreateWorktrees === "boolean")
|
|
7496
|
+
overlay.autoCreateWorktrees = linear.autoCreateWorktrees;
|
|
7497
|
+
if (typeof linear.createTicketOption === "boolean")
|
|
7498
|
+
overlay.createTicketOption = linear.createTicketOption;
|
|
7499
|
+
if (typeof linear.teamId === "string" && linear.teamId.trim())
|
|
7500
|
+
overlay.teamId = linear.teamId.trim();
|
|
7501
|
+
return Object.keys(overlay).length > 0 ? overlay : null;
|
|
7502
|
+
}
|
|
7482
7503
|
function loadLocalProjectConfigOverlay(root) {
|
|
7483
7504
|
try {
|
|
7484
7505
|
const text = readLocalConfigFile(root).trim();
|
|
7485
7506
|
if (!text) {
|
|
7486
|
-
return { worktreeRoot: null, profiles: {}, lifecycleHooks: {} };
|
|
7507
|
+
return { worktreeRoot: null, profiles: {}, lifecycleHooks: {}, linear: null };
|
|
7487
7508
|
}
|
|
7488
7509
|
const parsed = parseConfigDocument(text);
|
|
7489
7510
|
const ws = isRecord(parsed.workspace) ? parsed.workspace : null;
|
|
7490
7511
|
return {
|
|
7491
7512
|
worktreeRoot: ws && typeof ws.worktreeRoot === "string" ? ws.worktreeRoot : null,
|
|
7492
7513
|
profiles: parseProfiles(parsed.profiles, false),
|
|
7493
|
-
lifecycleHooks: parseLifecycleHooks(parsed.lifecycleHooks)
|
|
7514
|
+
lifecycleHooks: parseLifecycleHooks(parsed.lifecycleHooks),
|
|
7515
|
+
linear: parseLocalLinearOverlay(parsed)
|
|
7494
7516
|
};
|
|
7495
7517
|
} catch {
|
|
7496
|
-
return { worktreeRoot: null, profiles: {}, lifecycleHooks: {} };
|
|
7518
|
+
return { worktreeRoot: null, profiles: {}, lifecycleHooks: {}, linear: null };
|
|
7497
7519
|
}
|
|
7498
7520
|
}
|
|
7499
7521
|
function mergeHookCommand(projectCommand, localCommand) {
|
|
@@ -7544,9 +7566,31 @@ function loadConfig(dir, options = {}) {
|
|
|
7544
7566
|
...cloneProfiles(projectConfig.profiles),
|
|
7545
7567
|
...cloneProfiles(localOverlay.profiles)
|
|
7546
7568
|
},
|
|
7547
|
-
lifecycleHooks: mergeLifecycleHooks(projectConfig.lifecycleHooks, localOverlay.lifecycleHooks)
|
|
7569
|
+
lifecycleHooks: mergeLifecycleHooks(projectConfig.lifecycleHooks, localOverlay.lifecycleHooks),
|
|
7570
|
+
...localOverlay.linear ? {
|
|
7571
|
+
integrations: {
|
|
7572
|
+
...projectConfig.integrations,
|
|
7573
|
+
linear: { ...projectConfig.integrations.linear, ...localOverlay.linear }
|
|
7574
|
+
}
|
|
7575
|
+
} : {}
|
|
7548
7576
|
};
|
|
7549
7577
|
}
|
|
7578
|
+
async function persistLocalLinearConfig(dir, changes) {
|
|
7579
|
+
const root = projectRoot(dir);
|
|
7580
|
+
const localPath = join(root, ".webmux.local.yaml");
|
|
7581
|
+
let existing = {};
|
|
7582
|
+
try {
|
|
7583
|
+
const text = readFileSync(localPath, "utf8").trim();
|
|
7584
|
+
if (text)
|
|
7585
|
+
existing = parseConfigDocument(text);
|
|
7586
|
+
} catch {}
|
|
7587
|
+
const integrations = isRecord(existing.integrations) ? { ...existing.integrations } : {};
|
|
7588
|
+
const linear = isRecord(integrations.linear) ? { ...integrations.linear } : {};
|
|
7589
|
+
Object.assign(linear, changes);
|
|
7590
|
+
integrations.linear = linear;
|
|
7591
|
+
existing.integrations = integrations;
|
|
7592
|
+
await Bun.write(localPath, $stringify(existing));
|
|
7593
|
+
}
|
|
7550
7594
|
function expandTemplate(template, env) {
|
|
7551
7595
|
return template.replace(/\$\{(\w+)\}/g, (_, key) => env[key] ?? "");
|
|
7552
7596
|
}
|
|
@@ -7601,32 +7645,166 @@ var ASSIGNED_ISSUES_QUERY = `
|
|
|
7601
7645
|
}
|
|
7602
7646
|
}
|
|
7603
7647
|
`;
|
|
7648
|
+
var VIEWER_QUERY = `
|
|
7649
|
+
query Viewer {
|
|
7650
|
+
viewer {
|
|
7651
|
+
id
|
|
7652
|
+
}
|
|
7653
|
+
}
|
|
7654
|
+
`;
|
|
7655
|
+
var TEAM_STATES_QUERY = `
|
|
7656
|
+
query TeamStates($teamId: String!) {
|
|
7657
|
+
team(id: $teamId) {
|
|
7658
|
+
states {
|
|
7659
|
+
nodes {
|
|
7660
|
+
id
|
|
7661
|
+
name
|
|
7662
|
+
type
|
|
7663
|
+
}
|
|
7664
|
+
}
|
|
7665
|
+
}
|
|
7666
|
+
}
|
|
7667
|
+
`;
|
|
7668
|
+
var ISSUE_CREATE_MUTATION = `
|
|
7669
|
+
mutation IssueCreate($input: IssueCreateInput!) {
|
|
7670
|
+
issueCreate(input: $input) {
|
|
7671
|
+
success
|
|
7672
|
+
issue {
|
|
7673
|
+
id
|
|
7674
|
+
identifier
|
|
7675
|
+
title
|
|
7676
|
+
url
|
|
7677
|
+
branchName
|
|
7678
|
+
}
|
|
7679
|
+
}
|
|
7680
|
+
}
|
|
7681
|
+
`;
|
|
7682
|
+
function gqlErrorMessage(raw) {
|
|
7683
|
+
return raw.errors && raw.errors.length > 0 ? raw.errors.map((error) => error.message).join("; ") : null;
|
|
7684
|
+
}
|
|
7604
7685
|
function parseIssuesResponse(raw) {
|
|
7605
|
-
|
|
7606
|
-
|
|
7686
|
+
const error = gqlErrorMessage(raw);
|
|
7687
|
+
if (error) {
|
|
7688
|
+
return { ok: false, error };
|
|
7607
7689
|
}
|
|
7608
7690
|
if (!raw.data) {
|
|
7609
7691
|
return { ok: false, error: "No data in response" };
|
|
7610
7692
|
}
|
|
7611
|
-
const
|
|
7612
|
-
|
|
7613
|
-
|
|
7614
|
-
|
|
7615
|
-
|
|
7616
|
-
|
|
7617
|
-
|
|
7618
|
-
|
|
7619
|
-
|
|
7620
|
-
|
|
7621
|
-
|
|
7622
|
-
|
|
7623
|
-
|
|
7624
|
-
|
|
7625
|
-
|
|
7626
|
-
project: n.project?.name ?? null
|
|
7693
|
+
const issues = raw.data.viewer.assignedIssues.nodes.map((node) => ({
|
|
7694
|
+
id: node.id,
|
|
7695
|
+
identifier: node.identifier,
|
|
7696
|
+
title: node.title,
|
|
7697
|
+
description: node.description,
|
|
7698
|
+
priority: node.priority,
|
|
7699
|
+
priorityLabel: node.priorityLabel,
|
|
7700
|
+
url: node.url,
|
|
7701
|
+
branchName: node.branchName,
|
|
7702
|
+
dueDate: node.dueDate,
|
|
7703
|
+
updatedAt: node.updatedAt,
|
|
7704
|
+
state: node.state,
|
|
7705
|
+
team: node.team,
|
|
7706
|
+
labels: node.labels.nodes,
|
|
7707
|
+
project: node.project?.name ?? null
|
|
7627
7708
|
}));
|
|
7628
7709
|
return { ok: true, data: issues };
|
|
7629
7710
|
}
|
|
7711
|
+
function buildLinearIssuesResponse(input) {
|
|
7712
|
+
if (!input.integrationEnabled) {
|
|
7713
|
+
return {
|
|
7714
|
+
ok: true,
|
|
7715
|
+
data: {
|
|
7716
|
+
availability: "disabled",
|
|
7717
|
+
issues: []
|
|
7718
|
+
}
|
|
7719
|
+
};
|
|
7720
|
+
}
|
|
7721
|
+
if (!input.apiKey?.trim()) {
|
|
7722
|
+
return {
|
|
7723
|
+
ok: true,
|
|
7724
|
+
data: {
|
|
7725
|
+
availability: "missing_api_key",
|
|
7726
|
+
issues: []
|
|
7727
|
+
}
|
|
7728
|
+
};
|
|
7729
|
+
}
|
|
7730
|
+
if (!input.fetchResult) {
|
|
7731
|
+
return { ok: false, error: "Linear fetch result required when LINEAR_API_KEY is set" };
|
|
7732
|
+
}
|
|
7733
|
+
if (!input.fetchResult.ok) {
|
|
7734
|
+
return input.fetchResult;
|
|
7735
|
+
}
|
|
7736
|
+
return {
|
|
7737
|
+
ok: true,
|
|
7738
|
+
data: {
|
|
7739
|
+
availability: "ready",
|
|
7740
|
+
issues: input.fetchResult.data
|
|
7741
|
+
}
|
|
7742
|
+
};
|
|
7743
|
+
}
|
|
7744
|
+
function parseViewerIdResponse(raw) {
|
|
7745
|
+
const error = gqlErrorMessage(raw);
|
|
7746
|
+
if (error) {
|
|
7747
|
+
return { ok: false, error };
|
|
7748
|
+
}
|
|
7749
|
+
const viewerId = raw.data?.viewer.id;
|
|
7750
|
+
if (!viewerId) {
|
|
7751
|
+
return { ok: false, error: "No viewer id in response" };
|
|
7752
|
+
}
|
|
7753
|
+
return { ok: true, data: viewerId };
|
|
7754
|
+
}
|
|
7755
|
+
function parseInProgressStateIdResponse(raw) {
|
|
7756
|
+
const error = gqlErrorMessage(raw);
|
|
7757
|
+
if (error) {
|
|
7758
|
+
return { ok: false, error };
|
|
7759
|
+
}
|
|
7760
|
+
const states = raw.data?.team?.states.nodes;
|
|
7761
|
+
if (!states) {
|
|
7762
|
+
return { ok: false, error: "No team states in response" };
|
|
7763
|
+
}
|
|
7764
|
+
const preferredState = states.find((state) => state.type === "started" && state.name.trim().toLowerCase() === "in progress");
|
|
7765
|
+
if (preferredState) {
|
|
7766
|
+
return { ok: true, data: preferredState.id };
|
|
7767
|
+
}
|
|
7768
|
+
const startedState = states.find((state) => state.type === "started");
|
|
7769
|
+
if (!startedState) {
|
|
7770
|
+
return { ok: false, error: "No started workflow state found for team" };
|
|
7771
|
+
}
|
|
7772
|
+
return { ok: true, data: startedState.id };
|
|
7773
|
+
}
|
|
7774
|
+
function parseIssueCreateResponse(raw) {
|
|
7775
|
+
const error = gqlErrorMessage(raw);
|
|
7776
|
+
if (error) {
|
|
7777
|
+
return { ok: false, error };
|
|
7778
|
+
}
|
|
7779
|
+
const payload = raw.data?.issueCreate;
|
|
7780
|
+
if (!payload) {
|
|
7781
|
+
return { ok: false, error: "No issueCreate payload in response" };
|
|
7782
|
+
}
|
|
7783
|
+
if (!payload.success || !payload.issue) {
|
|
7784
|
+
return { ok: false, error: "Linear issue creation was not successful" };
|
|
7785
|
+
}
|
|
7786
|
+
if (!payload.issue.branchName) {
|
|
7787
|
+
return { ok: false, error: "Linear issue did not return a branch name" };
|
|
7788
|
+
}
|
|
7789
|
+
return {
|
|
7790
|
+
ok: true,
|
|
7791
|
+
data: {
|
|
7792
|
+
id: payload.issue.id,
|
|
7793
|
+
identifier: payload.issue.identifier,
|
|
7794
|
+
title: payload.issue.title,
|
|
7795
|
+
url: payload.issue.url,
|
|
7796
|
+
branchName: payload.issue.branchName
|
|
7797
|
+
}
|
|
7798
|
+
};
|
|
7799
|
+
}
|
|
7800
|
+
function deriveLinearIssueTitle(explicitTitle, prompt) {
|
|
7801
|
+
const trimmedTitle = explicitTitle?.trim();
|
|
7802
|
+
if (trimmedTitle) {
|
|
7803
|
+
return trimmedTitle;
|
|
7804
|
+
}
|
|
7805
|
+
const firstPromptLine = prompt?.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0);
|
|
7806
|
+
return firstPromptLine ?? null;
|
|
7807
|
+
}
|
|
7630
7808
|
function branchMatchesIssue(worktreeBranch, issueBranchName) {
|
|
7631
7809
|
if (!worktreeBranch || !issueBranchName)
|
|
7632
7810
|
return false;
|
|
@@ -7650,15 +7828,13 @@ function branchMatchesIssue(worktreeBranch, issueBranchName) {
|
|
|
7650
7828
|
}
|
|
7651
7829
|
var CACHE_TTL_MS = 300000;
|
|
7652
7830
|
var issueCache = null;
|
|
7653
|
-
|
|
7831
|
+
var viewerIdCache = null;
|
|
7832
|
+
var inProgressStateIdCache = new Map;
|
|
7833
|
+
async function postLinearGraphql(query, variables) {
|
|
7654
7834
|
const apiKey = Bun.env.LINEAR_API_KEY;
|
|
7655
7835
|
if (!apiKey) {
|
|
7656
7836
|
return { ok: false, error: "LINEAR_API_KEY not set" };
|
|
7657
7837
|
}
|
|
7658
|
-
const now = Date.now();
|
|
7659
|
-
if (issueCache && now < issueCache.expiry) {
|
|
7660
|
-
return issueCache.data;
|
|
7661
|
-
}
|
|
7662
7838
|
try {
|
|
7663
7839
|
const res = await fetch("https://api.linear.app/graphql", {
|
|
7664
7840
|
method: "POST",
|
|
@@ -7666,28 +7842,106 @@ async function fetchAssignedIssues() {
|
|
|
7666
7842
|
"Content-Type": "application/json",
|
|
7667
7843
|
Authorization: apiKey
|
|
7668
7844
|
},
|
|
7669
|
-
body: JSON.stringify({ query:
|
|
7845
|
+
body: JSON.stringify(variables ? { query, variables } : { query })
|
|
7670
7846
|
});
|
|
7671
7847
|
if (!res.ok) {
|
|
7672
7848
|
const text = await res.text();
|
|
7673
|
-
|
|
7674
|
-
return result2;
|
|
7675
|
-
}
|
|
7676
|
-
const json = await res.json();
|
|
7677
|
-
const result = parseIssuesResponse(json);
|
|
7678
|
-
if (result.ok) {
|
|
7679
|
-
issueCache = { data: result, expiry: now + CACHE_TTL_MS };
|
|
7680
|
-
log.debug(`[linear] fetched ${result.data.length} assigned issues`);
|
|
7681
|
-
} else {
|
|
7682
|
-
log.error(`[linear] GraphQL error: ${result.error}`);
|
|
7849
|
+
return { ok: false, error: `Linear API ${res.status}: ${text.slice(0, 200)}` };
|
|
7683
7850
|
}
|
|
7684
|
-
return
|
|
7851
|
+
return {
|
|
7852
|
+
ok: true,
|
|
7853
|
+
data: await res.json()
|
|
7854
|
+
};
|
|
7685
7855
|
} catch (err) {
|
|
7686
7856
|
const msg = err instanceof Error ? err.message : String(err);
|
|
7687
|
-
log.error(`[linear] fetch failed: ${msg}`);
|
|
7688
7857
|
return { ok: false, error: msg };
|
|
7689
7858
|
}
|
|
7690
7859
|
}
|
|
7860
|
+
async function fetchViewerId() {
|
|
7861
|
+
if (viewerIdCache) {
|
|
7862
|
+
return { ok: true, data: viewerIdCache };
|
|
7863
|
+
}
|
|
7864
|
+
const response = await postLinearGraphql(VIEWER_QUERY);
|
|
7865
|
+
if (!response.ok) {
|
|
7866
|
+
log.error(`[linear] viewer fetch failed: ${response.error}`);
|
|
7867
|
+
return { ok: false, error: response.error };
|
|
7868
|
+
}
|
|
7869
|
+
const result = parseViewerIdResponse(response.data);
|
|
7870
|
+
if (!result.ok) {
|
|
7871
|
+
log.error(`[linear] viewer GraphQL error: ${result.error}`);
|
|
7872
|
+
return result;
|
|
7873
|
+
}
|
|
7874
|
+
viewerIdCache = result.data;
|
|
7875
|
+
return result;
|
|
7876
|
+
}
|
|
7877
|
+
async function fetchInProgressStateId(teamId) {
|
|
7878
|
+
const cachedStateId = inProgressStateIdCache.get(teamId);
|
|
7879
|
+
if (cachedStateId) {
|
|
7880
|
+
return { ok: true, data: cachedStateId };
|
|
7881
|
+
}
|
|
7882
|
+
const response = await postLinearGraphql(TEAM_STATES_QUERY, { teamId });
|
|
7883
|
+
if (!response.ok) {
|
|
7884
|
+
log.error(`[linear] team states fetch failed: ${response.error}`);
|
|
7885
|
+
return { ok: false, error: response.error };
|
|
7886
|
+
}
|
|
7887
|
+
const result = parseInProgressStateIdResponse(response.data);
|
|
7888
|
+
if (!result.ok) {
|
|
7889
|
+
log.error(`[linear] team states GraphQL error: ${result.error}`);
|
|
7890
|
+
return result;
|
|
7891
|
+
}
|
|
7892
|
+
inProgressStateIdCache.set(teamId, result.data);
|
|
7893
|
+
return result;
|
|
7894
|
+
}
|
|
7895
|
+
async function fetchAssignedIssues(options) {
|
|
7896
|
+
const now = Date.now();
|
|
7897
|
+
if (!options?.skipCache && issueCache && now < issueCache.expiry) {
|
|
7898
|
+
return issueCache.data;
|
|
7899
|
+
}
|
|
7900
|
+
const response = await postLinearGraphql(ASSIGNED_ISSUES_QUERY);
|
|
7901
|
+
if (!response.ok) {
|
|
7902
|
+
log.error(`[linear] fetch failed: ${response.error}`);
|
|
7903
|
+
return { ok: false, error: response.error };
|
|
7904
|
+
}
|
|
7905
|
+
const result = parseIssuesResponse(response.data);
|
|
7906
|
+
if (result.ok) {
|
|
7907
|
+
issueCache = { data: result, expiry: now + CACHE_TTL_MS };
|
|
7908
|
+
log.debug(`[linear] fetched ${result.data.length} assigned issues`);
|
|
7909
|
+
} else {
|
|
7910
|
+
log.error(`[linear] GraphQL error: ${result.error}`);
|
|
7911
|
+
}
|
|
7912
|
+
return result;
|
|
7913
|
+
}
|
|
7914
|
+
async function createLinearIssue(input) {
|
|
7915
|
+
const viewerResult = await fetchViewerId();
|
|
7916
|
+
if (!viewerResult.ok) {
|
|
7917
|
+
return { ok: false, error: viewerResult.error };
|
|
7918
|
+
}
|
|
7919
|
+
const stateResult = await fetchInProgressStateId(input.teamId);
|
|
7920
|
+
if (!stateResult.ok) {
|
|
7921
|
+
return { ok: false, error: stateResult.error };
|
|
7922
|
+
}
|
|
7923
|
+
const response = await postLinearGraphql(ISSUE_CREATE_MUTATION, {
|
|
7924
|
+
input: {
|
|
7925
|
+
title: input.title,
|
|
7926
|
+
description: input.description,
|
|
7927
|
+
teamId: input.teamId,
|
|
7928
|
+
assigneeId: viewerResult.data,
|
|
7929
|
+
stateId: stateResult.data
|
|
7930
|
+
}
|
|
7931
|
+
});
|
|
7932
|
+
if (!response.ok) {
|
|
7933
|
+
log.error(`[linear] create failed: ${response.error}`);
|
|
7934
|
+
return { ok: false, error: response.error };
|
|
7935
|
+
}
|
|
7936
|
+
const result = parseIssueCreateResponse(response.data);
|
|
7937
|
+
if (result.ok) {
|
|
7938
|
+
issueCache = null;
|
|
7939
|
+
log.debug(`[linear] created issue ${result.data.identifier} branch=${result.data.branchName}`);
|
|
7940
|
+
} else {
|
|
7941
|
+
log.error(`[linear] issueCreate error: ${result.error}`);
|
|
7942
|
+
}
|
|
7943
|
+
return result;
|
|
7944
|
+
}
|
|
7691
7945
|
|
|
7692
7946
|
// backend/src/services/lifecycle-service.ts
|
|
7693
7947
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
@@ -8521,7 +8775,10 @@ function listLocalGitBranches(cwd) {
|
|
|
8521
8775
|
function readGitWorktreeStatus(cwd) {
|
|
8522
8776
|
const dirtyOutput = runGit(["status", "--porcelain"], cwd);
|
|
8523
8777
|
const commit = tryRunGit(["rev-parse", "HEAD"], cwd);
|
|
8524
|
-
|
|
8778
|
+
let ahead = tryRunGit(["rev-list", "--count", "@{upstream}..HEAD"], cwd);
|
|
8779
|
+
if (!ahead.ok) {
|
|
8780
|
+
ahead = tryRunGit(["rev-list", "--count", "HEAD", "--not", "--remotes=origin"], cwd);
|
|
8781
|
+
}
|
|
8525
8782
|
return {
|
|
8526
8783
|
dirty: dirtyOutput.length > 0,
|
|
8527
8784
|
aheadCount: ahead.ok ? parseInt(ahead.stdout, 10) || 0 : 0,
|
|
@@ -8620,9 +8877,21 @@ class BunGitGateway {
|
|
|
8620
8877
|
const result = tryRunGit(["diff", "HEAD", "--no-color"], cwd);
|
|
8621
8878
|
return result.ok ? result.stdout : "";
|
|
8622
8879
|
}
|
|
8623
|
-
|
|
8624
|
-
|
|
8625
|
-
|
|
8880
|
+
listUnpushedCommits(cwd) {
|
|
8881
|
+
let result = tryRunGit(["log", "--oneline", "@{upstream}..HEAD"], cwd);
|
|
8882
|
+
if (!result.ok) {
|
|
8883
|
+
result = tryRunGit(["log", "--oneline", "HEAD", "--not", "--remotes=origin"], cwd);
|
|
8884
|
+
}
|
|
8885
|
+
if (!result.ok || !result.stdout)
|
|
8886
|
+
return [];
|
|
8887
|
+
return result.stdout.split(`
|
|
8888
|
+
`).filter((line) => line.length > 0).map((line) => {
|
|
8889
|
+
const spaceIdx = line.indexOf(" ");
|
|
8890
|
+
return {
|
|
8891
|
+
hash: line.slice(0, spaceIdx),
|
|
8892
|
+
message: line.slice(spaceIdx + 1)
|
|
8893
|
+
};
|
|
8894
|
+
});
|
|
8626
8895
|
}
|
|
8627
8896
|
}
|
|
8628
8897
|
|
|
@@ -8882,7 +9151,7 @@ class LifecycleService {
|
|
|
8882
9151
|
agent,
|
|
8883
9152
|
phase: "reconciling"
|
|
8884
9153
|
});
|
|
8885
|
-
await this.deps.reconciliation.reconcile(this.deps.projectRoot);
|
|
9154
|
+
await this.deps.reconciliation.reconcile(this.deps.projectRoot, { force: true });
|
|
8886
9155
|
return {
|
|
8887
9156
|
branch,
|
|
8888
9157
|
worktreeId: initialized.meta.worktreeId
|
|
@@ -8917,7 +9186,7 @@ class LifecycleService {
|
|
|
8917
9186
|
worktreePath: resolved.entry.path,
|
|
8918
9187
|
launchMode
|
|
8919
9188
|
});
|
|
8920
|
-
await this.deps.reconciliation.reconcile(this.deps.projectRoot);
|
|
9189
|
+
await this.deps.reconciliation.reconcile(this.deps.projectRoot, { force: true });
|
|
8921
9190
|
return {
|
|
8922
9191
|
branch,
|
|
8923
9192
|
worktreeId: initialized.meta.worktreeId
|
|
@@ -8930,7 +9199,7 @@ class LifecycleService {
|
|
|
8930
9199
|
try {
|
|
8931
9200
|
const resolved = await this.resolveExistingWorktree(branch);
|
|
8932
9201
|
this.deps.tmux.killWindow(buildProjectSessionName(this.deps.projectRoot), buildWorktreeWindowName(branch));
|
|
8933
|
-
await this.deps.reconciliation.reconcile(this.deps.projectRoot);
|
|
9202
|
+
await this.deps.reconciliation.reconcile(this.deps.projectRoot, { force: true });
|
|
8934
9203
|
} catch (error) {
|
|
8935
9204
|
throw this.wrapOperationError(error);
|
|
8936
9205
|
}
|
|
@@ -9256,15 +9525,15 @@ class LifecycleService {
|
|
|
9256
9525
|
deleteBranch: true,
|
|
9257
9526
|
deleteBranchForce: true
|
|
9258
9527
|
}, this.deps.git);
|
|
9259
|
-
await this.deps.reconciliation.reconcile(this.deps.projectRoot);
|
|
9528
|
+
await this.deps.reconciliation.reconcile(this.deps.projectRoot, { force: true });
|
|
9260
9529
|
}
|
|
9261
9530
|
async runLifecycleHook(input) {
|
|
9262
|
-
|
|
9531
|
+
log.debug(`[lifecycle-hook] name=${input.name} command=${input.command ?? "UNDEFINED"} meta=${input.meta ? "present" : "NULL"} cwd=${input.worktreePath}`);
|
|
9263
9532
|
if (!input.command || !input.meta) {
|
|
9264
|
-
|
|
9533
|
+
log.debug(`[lifecycle-hook] SKIPPING ${input.name}: command=${!!input.command} meta=${!!input.meta}`);
|
|
9265
9534
|
return;
|
|
9266
9535
|
}
|
|
9267
|
-
|
|
9536
|
+
log.debug(`[lifecycle-hook] RUNNING ${input.name}: ${input.command} in ${input.worktreePath}`);
|
|
9268
9537
|
const dotenvValues = await loadDotenvLocal(input.worktreePath);
|
|
9269
9538
|
await this.deps.hooks.run({
|
|
9270
9539
|
name: input.name,
|
|
@@ -9274,7 +9543,7 @@ class LifecycleService {
|
|
|
9274
9543
|
WEBMUX_WORKTREE_PATH: input.worktreePath
|
|
9275
9544
|
}, dotenvValues)
|
|
9276
9545
|
});
|
|
9277
|
-
|
|
9546
|
+
log.debug(`[lifecycle-hook] COMPLETED ${input.name}`);
|
|
9278
9547
|
}
|
|
9279
9548
|
async reportCreateProgress(progress) {
|
|
9280
9549
|
await this.deps.onCreateProgress?.(progress);
|
|
@@ -9290,6 +9559,117 @@ class LifecycleService {
|
|
|
9290
9559
|
}
|
|
9291
9560
|
}
|
|
9292
9561
|
|
|
9562
|
+
// backend/src/services/native-terminal-service.ts
|
|
9563
|
+
function quoteShell2(value) {
|
|
9564
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
9565
|
+
}
|
|
9566
|
+
function sanitizeSessionSuffix(value) {
|
|
9567
|
+
const sanitized = value.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/-{2,}/g, "-").replace(/^[.-]+|[.-]+$/g, "");
|
|
9568
|
+
const trimmed = sanitized.slice(0, 24);
|
|
9569
|
+
return trimmed || "x";
|
|
9570
|
+
}
|
|
9571
|
+
function buildNativeTerminalTmuxCommand(env) {
|
|
9572
|
+
const socket = env.WEBMUX_ISOLATED_TMUX_SOCKET;
|
|
9573
|
+
const config = env.WEBMUX_ISOLATED_TMUX_CONFIG;
|
|
9574
|
+
if (socket && config) {
|
|
9575
|
+
return `tmux -L ${quoteShell2(socket)} -f ${quoteShell2(config)}`;
|
|
9576
|
+
}
|
|
9577
|
+
if (socket) {
|
|
9578
|
+
return `tmux -L ${quoteShell2(socket)}`;
|
|
9579
|
+
}
|
|
9580
|
+
return "tmux";
|
|
9581
|
+
}
|
|
9582
|
+
function buildNativeTerminalLaunch(input) {
|
|
9583
|
+
const { branch, state, tmuxCommand } = input;
|
|
9584
|
+
if (!state || !state.git.exists) {
|
|
9585
|
+
return {
|
|
9586
|
+
ok: false,
|
|
9587
|
+
reason: "not_found",
|
|
9588
|
+
message: `Worktree not found: ${branch}`
|
|
9589
|
+
};
|
|
9590
|
+
}
|
|
9591
|
+
if (!state.session.exists || !state.session.sessionName) {
|
|
9592
|
+
return {
|
|
9593
|
+
ok: false,
|
|
9594
|
+
reason: "closed",
|
|
9595
|
+
message: `No open tmux window found for worktree: ${branch}`
|
|
9596
|
+
};
|
|
9597
|
+
}
|
|
9598
|
+
const sessionPrefix = input.sessionPrefix ?? "wm-native-launch-";
|
|
9599
|
+
const groupedSessionPrefix = `${sessionPrefix}${sanitizeSessionSuffix(state.worktreeId)}`;
|
|
9600
|
+
const attachScript = [
|
|
9601
|
+
`g_name="${groupedSessionPrefix}-${"$"}$-$(date +%s)"`,
|
|
9602
|
+
`owner_session_name=${quoteShell2(state.session.sessionName)}`,
|
|
9603
|
+
`window_name=${quoteShell2(state.session.windowName)}`,
|
|
9604
|
+
`grouped_window_target="${"$"}g_name:${"$"}window_name"`,
|
|
9605
|
+
`grouped_pane_target="${"$"}grouped_window_target.0"`,
|
|
9606
|
+
`cleanup() { ${tmuxCommand} kill-session -t "${"$"}g_name" >/dev/null 2>&1 || true; }`,
|
|
9607
|
+
"cleanup",
|
|
9608
|
+
`${tmuxCommand} new-session -d -s "${"$"}g_name" -t "${"$"}owner_session_name"`,
|
|
9609
|
+
`${tmuxCommand} set-option -t "${"$"}owner_session_name" window-size latest`,
|
|
9610
|
+
`${tmuxCommand} set-option -t "${"$"}g_name" mouse on`,
|
|
9611
|
+
`${tmuxCommand} set-option -t "${"$"}g_name" set-clipboard on`,
|
|
9612
|
+
`${tmuxCommand} select-window -t "${"$"}grouped_window_target"`,
|
|
9613
|
+
`if [ "$(${tmuxCommand} display-message -t "${"$"}grouped_window_target" -p '#{window_zoomed_flag}')" = "1" ]; then ${tmuxCommand} resize-pane -Z -t "${"$"}grouped_window_target"; fi`,
|
|
9614
|
+
`${tmuxCommand} select-pane -t "${"$"}grouped_pane_target"`,
|
|
9615
|
+
"trap cleanup EXIT INT TERM",
|
|
9616
|
+
`exec ${tmuxCommand} attach-session -t "${"$"}g_name"`
|
|
9617
|
+
].join(" && ");
|
|
9618
|
+
return {
|
|
9619
|
+
ok: true,
|
|
9620
|
+
data: {
|
|
9621
|
+
worktreeId: state.worktreeId,
|
|
9622
|
+
branch: state.branch,
|
|
9623
|
+
path: state.path,
|
|
9624
|
+
shellCommand: `/bin/sh -lc ${quoteShell2(attachScript)}`
|
|
9625
|
+
}
|
|
9626
|
+
};
|
|
9627
|
+
}
|
|
9628
|
+
|
|
9629
|
+
// backend/src/lib/async.ts
|
|
9630
|
+
async function mapWithConcurrency(items, limit, fn) {
|
|
9631
|
+
const results = new Array(items.length);
|
|
9632
|
+
let next = 0;
|
|
9633
|
+
const concurrency = Math.max(1, Math.min(limit, items.length || 1));
|
|
9634
|
+
async function worker() {
|
|
9635
|
+
while (next < items.length) {
|
|
9636
|
+
const index = next++;
|
|
9637
|
+
results[index] = await fn(items[index]);
|
|
9638
|
+
}
|
|
9639
|
+
}
|
|
9640
|
+
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
|
9641
|
+
return results;
|
|
9642
|
+
}
|
|
9643
|
+
function startSerializedInterval(run, intervalMs, deps = {}) {
|
|
9644
|
+
const scheduleEvery = deps.scheduleEvery ?? ((handler, ms) => setInterval(handler, ms));
|
|
9645
|
+
const cancelSchedule = deps.cancelSchedule ?? ((handle2) => clearInterval(handle2));
|
|
9646
|
+
let running = false;
|
|
9647
|
+
let rerunRequested = false;
|
|
9648
|
+
let stopped = false;
|
|
9649
|
+
const execute = () => {
|
|
9650
|
+
if (stopped)
|
|
9651
|
+
return;
|
|
9652
|
+
if (running) {
|
|
9653
|
+
rerunRequested = true;
|
|
9654
|
+
return;
|
|
9655
|
+
}
|
|
9656
|
+
running = true;
|
|
9657
|
+
Promise.resolve().then(run).finally(() => {
|
|
9658
|
+
running = false;
|
|
9659
|
+
if (stopped || !rerunRequested)
|
|
9660
|
+
return;
|
|
9661
|
+
rerunRequested = false;
|
|
9662
|
+
execute();
|
|
9663
|
+
});
|
|
9664
|
+
};
|
|
9665
|
+
execute();
|
|
9666
|
+
const handle = scheduleEvery(execute, intervalMs);
|
|
9667
|
+
return () => {
|
|
9668
|
+
stopped = true;
|
|
9669
|
+
cancelSchedule(handle);
|
|
9670
|
+
};
|
|
9671
|
+
}
|
|
9672
|
+
|
|
9293
9673
|
// backend/src/services/pr-service.ts
|
|
9294
9674
|
var PR_FETCH_LIMIT = 50;
|
|
9295
9675
|
var GH_TIMEOUT_MS = 15000;
|
|
@@ -9409,18 +9789,6 @@ async function fetchAllPrs(repoSlug, repoLabel, cwd) {
|
|
|
9409
9789
|
return { ok: false, error: `failed to parse gh output for ${label}: ${err}` };
|
|
9410
9790
|
}
|
|
9411
9791
|
}
|
|
9412
|
-
async function mapWithConcurrency(items, limit, fn) {
|
|
9413
|
-
const results = new Array(items.length);
|
|
9414
|
-
let next = 0;
|
|
9415
|
-
async function worker() {
|
|
9416
|
-
while (next < items.length) {
|
|
9417
|
-
const idx = next++;
|
|
9418
|
-
results[idx] = await fn(items[idx]);
|
|
9419
|
-
}
|
|
9420
|
-
}
|
|
9421
|
-
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
|
|
9422
|
-
return results;
|
|
9423
|
-
}
|
|
9424
9792
|
async function fetchReviewComments(prNumber, repoSlug, cwd) {
|
|
9425
9793
|
const repoFlag = repoSlug ? repoSlug : "{owner}/{repo}";
|
|
9426
9794
|
const apiPath = `repos/${repoFlag}/pulls/${prNumber}/comments?per_page=100`;
|
|
@@ -9606,21 +9974,76 @@ async function syncPrStatus(getWorktreeGitDirs, linkedRepos, projectDir) {
|
|
|
9606
9974
|
etagCache.delete(key);
|
|
9607
9975
|
}
|
|
9608
9976
|
}
|
|
9609
|
-
function startPrMonitor(getWorktreeGitDirs, linkedRepos, projectDir, intervalMs =
|
|
9610
|
-
const run = () => {
|
|
9977
|
+
function startPrMonitor(getWorktreeGitDirs, linkedRepos, projectDir, intervalMs = 1e4, isActive) {
|
|
9978
|
+
const run = async () => {
|
|
9611
9979
|
if (isActive && !isActive()) {
|
|
9612
9980
|
log.debug("[pr] skipping PR sync: no active clients");
|
|
9613
9981
|
return;
|
|
9614
9982
|
}
|
|
9615
|
-
syncPrStatus(getWorktreeGitDirs, linkedRepos, projectDir).catch((err) => {
|
|
9983
|
+
await syncPrStatus(getWorktreeGitDirs, linkedRepos, projectDir).catch((err) => {
|
|
9616
9984
|
log.error(`[pr] sync error: ${err}`);
|
|
9617
9985
|
});
|
|
9618
9986
|
};
|
|
9619
|
-
run
|
|
9620
|
-
|
|
9621
|
-
|
|
9622
|
-
|
|
9623
|
-
|
|
9987
|
+
return startSerializedInterval(run, intervalMs);
|
|
9988
|
+
}
|
|
9989
|
+
|
|
9990
|
+
// backend/src/services/linear-auto-create-service.ts
|
|
9991
|
+
var POLL_INTERVAL_MS = 15000;
|
|
9992
|
+
var processedIssueIds = new Set;
|
|
9993
|
+
var AUTO_CREATE_LABEL = "webmux";
|
|
9994
|
+
function filterAutoCreateIssues(issues, existingBranches) {
|
|
9995
|
+
return issues.filter((issue) => {
|
|
9996
|
+
if (issue.state.name !== "Todo")
|
|
9997
|
+
return false;
|
|
9998
|
+
if (!issue.labels.some((l) => l.name.toLowerCase() === AUTO_CREATE_LABEL))
|
|
9999
|
+
return false;
|
|
10000
|
+
if (processedIssueIds.has(issue.id))
|
|
10001
|
+
return false;
|
|
10002
|
+
return !existingBranches.some((branch) => branchMatchesIssue(branch, issue.branchName));
|
|
10003
|
+
});
|
|
10004
|
+
}
|
|
10005
|
+
async function runAutoCreate(deps) {
|
|
10006
|
+
if (!deps.isActive()) {
|
|
10007
|
+
log.debug("[linear-auto-create] skipping: no active clients");
|
|
10008
|
+
return;
|
|
10009
|
+
}
|
|
10010
|
+
const result = await fetchAssignedIssues({ skipCache: true });
|
|
10011
|
+
if (!result.ok) {
|
|
10012
|
+
log.error(`[linear-auto-create] failed to fetch issues: ${result.error}`);
|
|
10013
|
+
return;
|
|
10014
|
+
}
|
|
10015
|
+
const projectRoot2 = deps.projectRoot;
|
|
10016
|
+
const existingBranches = deps.git.listWorktrees(projectRoot2).filter((entry) => !entry.bare && entry.branch !== null).map((entry) => entry.branch);
|
|
10017
|
+
const newIssues = filterAutoCreateIssues(result.data, existingBranches);
|
|
10018
|
+
if (newIssues.length === 0) {
|
|
10019
|
+
log.debug(`[linear-auto-create] no new labeled issues (${result.data.length} assigned, ${existingBranches.length} worktrees)`);
|
|
10020
|
+
return;
|
|
10021
|
+
}
|
|
10022
|
+
log.info(`[linear-auto-create] found ${newIssues.length} new issue(s) with "${AUTO_CREATE_LABEL}" label`);
|
|
10023
|
+
for (const issue of newIssues) {
|
|
10024
|
+
try {
|
|
10025
|
+
log.info(`[linear-auto-create] creating worktree for ${issue.identifier}: ${issue.title}`);
|
|
10026
|
+
await deps.lifecycleService.createWorktree({
|
|
10027
|
+
mode: "new",
|
|
10028
|
+
branch: issue.branchName,
|
|
10029
|
+
prompt: `${issue.title}
|
|
10030
|
+
|
|
10031
|
+
${issue.description ?? ""}`.trim()
|
|
10032
|
+
});
|
|
10033
|
+
processedIssueIds.add(issue.id);
|
|
10034
|
+
log.info(`[linear-auto-create] created worktree for ${issue.identifier}`);
|
|
10035
|
+
} catch (err) {
|
|
10036
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
10037
|
+
log.error(`[linear-auto-create] failed to create worktree for ${issue.identifier}: ${msg}`);
|
|
10038
|
+
}
|
|
10039
|
+
}
|
|
10040
|
+
}
|
|
10041
|
+
function startLinearAutoCreateMonitor(deps) {
|
|
10042
|
+
log.info("[linear-auto-create] monitor started");
|
|
10043
|
+
return startSerializedInterval(() => runAutoCreate(deps), POLL_INTERVAL_MS);
|
|
10044
|
+
}
|
|
10045
|
+
function resetProcessedIssues() {
|
|
10046
|
+
processedIssueIds.clear();
|
|
9624
10047
|
}
|
|
9625
10048
|
|
|
9626
10049
|
// backend/src/services/snapshot-service.ts
|
|
@@ -9662,7 +10085,8 @@ function mapWorktreeSnapshot(state, now, creating, findLinearIssue) {
|
|
|
9662
10085
|
profile: state.profile,
|
|
9663
10086
|
agentName: state.agentName,
|
|
9664
10087
|
mux: state.session.exists,
|
|
9665
|
-
dirty: state.git.dirty
|
|
10088
|
+
dirty: state.git.dirty,
|
|
10089
|
+
unpushed: state.git.aheadCount > 0,
|
|
9666
10090
|
paneCount: state.session.paneCount,
|
|
9667
10091
|
status: creating ? "creating" : state.agent.lifecycle,
|
|
9668
10092
|
elapsed: formatElapsedSince(state.agent.lastStartedAt, now),
|
|
@@ -9681,6 +10105,7 @@ function mapCreatingWorktreeSnapshot(creating, findLinearIssue) {
|
|
|
9681
10105
|
agentName: creating.agentName,
|
|
9682
10106
|
mux: false,
|
|
9683
10107
|
dirty: false,
|
|
10108
|
+
unpushed: false,
|
|
9684
10109
|
paneCount: 0,
|
|
9685
10110
|
status: "creating",
|
|
9686
10111
|
elapsed: "",
|
|
@@ -10041,8 +10466,8 @@ class BunLifecycleHookRunner {
|
|
|
10041
10466
|
}
|
|
10042
10467
|
async run(input) {
|
|
10043
10468
|
const cmd = await this.buildCommand(input.cwd, input.command);
|
|
10044
|
-
|
|
10045
|
-
|
|
10469
|
+
log.debug(`[hook-runner] Spawning: ${cmd.join(" ")} cwd=${input.cwd}`);
|
|
10470
|
+
log.debug(`[hook-runner] envKeys=${Object.keys(input.env).length}`);
|
|
10046
10471
|
const proc = Bun.spawn(cmd, {
|
|
10047
10472
|
cwd: input.cwd,
|
|
10048
10473
|
env: {
|
|
@@ -10057,11 +10482,11 @@ class BunLifecycleHookRunner {
|
|
|
10057
10482
|
new Response(proc.stdout).text(),
|
|
10058
10483
|
new Response(proc.stderr).text()
|
|
10059
10484
|
]);
|
|
10060
|
-
|
|
10485
|
+
log.debug(`[hook-runner] ${input.name} exitCode=${exitCode}`);
|
|
10061
10486
|
if (stdout.trim())
|
|
10062
|
-
|
|
10487
|
+
log.debug(`[hook-runner] stdout: ${stdout.trim()}`);
|
|
10063
10488
|
if (stderr.trim())
|
|
10064
|
-
|
|
10489
|
+
log.debug(`[hook-runner] stderr: ${stderr.trim()}`);
|
|
10065
10490
|
if (exitCode !== 0) {
|
|
10066
10491
|
throw new Error(buildErrorMessage(input.name, exitCode, stdout, stderr));
|
|
10067
10492
|
}
|
|
@@ -10545,11 +10970,34 @@ function resolveBranch(entry, metaBranch) {
|
|
|
10545
10970
|
|
|
10546
10971
|
class ReconciliationService {
|
|
10547
10972
|
deps;
|
|
10548
|
-
|
|
10973
|
+
freshnessMs;
|
|
10974
|
+
now;
|
|
10975
|
+
concurrency;
|
|
10976
|
+
inFlight = null;
|
|
10977
|
+
lastReconciledAt = 0;
|
|
10978
|
+
constructor(deps, options = {}) {
|
|
10549
10979
|
this.deps = deps;
|
|
10980
|
+
this.freshnessMs = options.freshnessMs ?? 500;
|
|
10981
|
+
this.now = options.now ?? Date.now;
|
|
10982
|
+
this.concurrency = options.concurrency ?? 4;
|
|
10550
10983
|
}
|
|
10551
|
-
async reconcile(repoRoot) {
|
|
10984
|
+
async reconcile(repoRoot, options = {}) {
|
|
10985
|
+
if (this.inFlight) {
|
|
10986
|
+
return await this.inFlight;
|
|
10987
|
+
}
|
|
10988
|
+
if (!options.force && this.now() - this.lastReconciledAt < this.freshnessMs) {
|
|
10989
|
+
return;
|
|
10990
|
+
}
|
|
10552
10991
|
const normalizedRepoRoot = resolve5(repoRoot);
|
|
10992
|
+
const reconcilePromise = this.runReconcile(normalizedRepoRoot).then(() => {
|
|
10993
|
+
this.lastReconciledAt = this.now();
|
|
10994
|
+
});
|
|
10995
|
+
this.inFlight = reconcilePromise.finally(() => {
|
|
10996
|
+
this.inFlight = null;
|
|
10997
|
+
});
|
|
10998
|
+
return await this.inFlight;
|
|
10999
|
+
}
|
|
11000
|
+
async runReconcile(normalizedRepoRoot) {
|
|
10553
11001
|
const worktrees = this.deps.git.listWorktrees(normalizedRepoRoot);
|
|
10554
11002
|
const sessionName = buildProjectSessionName(normalizedRepoRoot);
|
|
10555
11003
|
let windows = [];
|
|
@@ -10559,40 +11007,32 @@ class ReconciliationService {
|
|
|
10559
11007
|
windows = [];
|
|
10560
11008
|
}
|
|
10561
11009
|
const seenWorktreeIds = new Set;
|
|
10562
|
-
|
|
10563
|
-
|
|
10564
|
-
continue;
|
|
10565
|
-
if (resolve5(entry.path) === normalizedRepoRoot)
|
|
10566
|
-
continue;
|
|
11010
|
+
const candidateEntries = worktrees.filter((entry) => !entry.bare && resolve5(entry.path) !== normalizedRepoRoot);
|
|
11011
|
+
const reconciledStates = await mapWithConcurrency(candidateEntries, this.concurrency, async (entry) => {
|
|
10567
11012
|
const gitDir = this.deps.git.resolveWorktreeGitDir(entry.path);
|
|
10568
11013
|
const meta = await readWorktreeMeta(gitDir);
|
|
10569
11014
|
const branch = resolveBranch(entry, meta?.branch ?? null);
|
|
10570
11015
|
const worktreeId = meta?.worktreeId ?? makeUnmanagedWorktreeId(entry.path);
|
|
10571
|
-
|
|
10572
|
-
|
|
11016
|
+
const gitStatus = this.deps.git.readWorktreeStatus(entry.path);
|
|
11017
|
+
const window = findWindow(windows, sessionName, branch);
|
|
11018
|
+
return {
|
|
10573
11019
|
worktreeId,
|
|
10574
11020
|
branch,
|
|
10575
11021
|
path: entry.path,
|
|
10576
11022
|
profile: meta?.profile ?? null,
|
|
10577
11023
|
agentName: meta?.agent ?? null,
|
|
10578
|
-
runtime: meta?.runtime ?? "host"
|
|
10579
|
-
|
|
10580
|
-
|
|
10581
|
-
|
|
10582
|
-
|
|
10583
|
-
|
|
10584
|
-
|
|
10585
|
-
|
|
10586
|
-
|
|
10587
|
-
|
|
10588
|
-
|
|
10589
|
-
|
|
10590
|
-
exists: window !== null,
|
|
10591
|
-
sessionName: window?.sessionName ?? null,
|
|
10592
|
-
paneCount: window?.paneCount ?? 0
|
|
10593
|
-
});
|
|
10594
|
-
if (meta) {
|
|
10595
|
-
this.deps.runtime.setServices(worktreeId, await buildServiceStates(this.deps, {
|
|
11024
|
+
runtime: meta?.runtime ?? "host",
|
|
11025
|
+
git: {
|
|
11026
|
+
dirty: gitStatus.dirty,
|
|
11027
|
+
aheadCount: gitStatus.aheadCount,
|
|
11028
|
+
currentCommit: gitStatus.currentCommit
|
|
11029
|
+
},
|
|
11030
|
+
session: {
|
|
11031
|
+
exists: window !== null,
|
|
11032
|
+
sessionName: window?.sessionName ?? null,
|
|
11033
|
+
paneCount: window?.paneCount ?? 0
|
|
11034
|
+
},
|
|
11035
|
+
services: meta ? await buildServiceStates(this.deps, {
|
|
10596
11036
|
allocatedPorts: meta.allocatedPorts,
|
|
10597
11037
|
startupEnvValues: meta.startupEnvValues,
|
|
10598
11038
|
worktreeId: meta.worktreeId,
|
|
@@ -10600,11 +11040,34 @@ class ReconciliationService {
|
|
|
10600
11040
|
profile: meta.profile,
|
|
10601
11041
|
agent: meta.agent,
|
|
10602
11042
|
runtime: meta.runtime
|
|
10603
|
-
})
|
|
10604
|
-
|
|
10605
|
-
|
|
10606
|
-
|
|
10607
|
-
|
|
11043
|
+
}) : [],
|
|
11044
|
+
prs: await readWorktreePrs(gitDir)
|
|
11045
|
+
};
|
|
11046
|
+
});
|
|
11047
|
+
for (const state of reconciledStates) {
|
|
11048
|
+
seenWorktreeIds.add(state.worktreeId);
|
|
11049
|
+
this.deps.runtime.upsertWorktree({
|
|
11050
|
+
worktreeId: state.worktreeId,
|
|
11051
|
+
branch: state.branch,
|
|
11052
|
+
path: state.path,
|
|
11053
|
+
profile: state.profile,
|
|
11054
|
+
agentName: state.agentName,
|
|
11055
|
+
runtime: state.runtime
|
|
11056
|
+
});
|
|
11057
|
+
this.deps.runtime.setGitState(state.worktreeId, {
|
|
11058
|
+
exists: true,
|
|
11059
|
+
branch: state.branch,
|
|
11060
|
+
dirty: state.git.dirty,
|
|
11061
|
+
aheadCount: state.git.aheadCount,
|
|
11062
|
+
currentCommit: state.git.currentCommit
|
|
11063
|
+
});
|
|
11064
|
+
this.deps.runtime.setSessionState(state.worktreeId, {
|
|
11065
|
+
exists: state.session.exists,
|
|
11066
|
+
sessionName: state.session.sessionName,
|
|
11067
|
+
paneCount: state.session.paneCount
|
|
11068
|
+
});
|
|
11069
|
+
this.deps.runtime.setServices(state.worktreeId, state.services);
|
|
11070
|
+
this.deps.runtime.setPrs(state.worktreeId, state.prs);
|
|
10608
11071
|
}
|
|
10609
11072
|
for (const state of this.deps.runtime.listWorktrees()) {
|
|
10610
11073
|
if (!seenWorktreeIds.has(state.worktreeId)) {
|
|
@@ -10712,6 +11175,24 @@ var runtimeNotifications = runtime.runtimeNotifications;
|
|
|
10712
11175
|
var reconciliationService = runtime.reconciliationService;
|
|
10713
11176
|
var removingBranches = new Set;
|
|
10714
11177
|
var lifecycleService = runtime.lifecycleService;
|
|
11178
|
+
var linearAutoCreateEnabled = config.integrations.linear.autoCreateWorktrees;
|
|
11179
|
+
var stopLinearAutoCreate = null;
|
|
11180
|
+
function startLinearAutoCreate() {
|
|
11181
|
+
if (stopLinearAutoCreate)
|
|
11182
|
+
return;
|
|
11183
|
+
stopLinearAutoCreate = startLinearAutoCreateMonitor({
|
|
11184
|
+
lifecycleService,
|
|
11185
|
+
git,
|
|
11186
|
+
projectRoot: PROJECT_DIR,
|
|
11187
|
+
isActive: hasRecentDashboardActivity
|
|
11188
|
+
});
|
|
11189
|
+
}
|
|
11190
|
+
function stopLinearAutoCreateMonitor() {
|
|
11191
|
+
if (stopLinearAutoCreate) {
|
|
11192
|
+
stopLinearAutoCreate();
|
|
11193
|
+
stopLinearAutoCreate = null;
|
|
11194
|
+
}
|
|
11195
|
+
}
|
|
10715
11196
|
function getFrontendConfig() {
|
|
10716
11197
|
const defaultProfileName = getDefaultProfileName(config);
|
|
10717
11198
|
const orderedProfileEntries = Object.entries(config.profiles).sort(([left], [right]) => {
|
|
@@ -10730,11 +11211,13 @@ function getFrontendConfig() {
|
|
|
10730
11211
|
})),
|
|
10731
11212
|
defaultProfileName,
|
|
10732
11213
|
autoName: config.autoName !== null,
|
|
11214
|
+
linearCreateTicketOption: config.integrations.linear.enabled && config.integrations.linear.createTicketOption,
|
|
10733
11215
|
startupEnvs: config.startupEnvs,
|
|
10734
11216
|
linkedRepos: config.integrations.github.linkedRepos.map((lr) => ({
|
|
10735
11217
|
alias: lr.alias,
|
|
10736
11218
|
...lr.dir ? { dir: resolve6(PROJECT_DIR, lr.dir) } : {}
|
|
10737
|
-
}))
|
|
11219
|
+
})),
|
|
11220
|
+
linearAutoCreateWorktrees: linearAutoCreateEnabled
|
|
10738
11221
|
};
|
|
10739
11222
|
}
|
|
10740
11223
|
function parseWsMessage(raw) {
|
|
@@ -10812,8 +11295,11 @@ async function withRemovingBranch(branch, fn) {
|
|
|
10812
11295
|
}
|
|
10813
11296
|
async function resolveTerminalWorktree(branch) {
|
|
10814
11297
|
ensureBranchNotBusy(branch);
|
|
10815
|
-
|
|
10816
|
-
|
|
11298
|
+
let state = projectRuntime.getWorktreeByBranch(branch);
|
|
11299
|
+
if (!state || !state.session.exists || !state.session.sessionName) {
|
|
11300
|
+
await reconciliationService.reconcile(PROJECT_DIR);
|
|
11301
|
+
state = projectRuntime.getWorktreeByBranch(branch);
|
|
11302
|
+
}
|
|
10817
11303
|
if (!state) {
|
|
10818
11304
|
throw new Error(`Worktree not found: ${branch}`);
|
|
10819
11305
|
}
|
|
@@ -10828,9 +11314,24 @@ async function resolveTerminalWorktree(branch) {
|
|
|
10828
11314
|
}
|
|
10829
11315
|
};
|
|
10830
11316
|
}
|
|
10831
|
-
function
|
|
10832
|
-
|
|
10833
|
-
|
|
11317
|
+
async function apiGetNativeTerminalLaunch(branch) {
|
|
11318
|
+
touchDashboardActivity();
|
|
11319
|
+
ensureBranchNotBusy(branch);
|
|
11320
|
+
await reconciliationService.reconcile(PROJECT_DIR);
|
|
11321
|
+
const launch = buildNativeTerminalLaunch({
|
|
11322
|
+
branch,
|
|
11323
|
+
state: projectRuntime.getWorktreeByBranch(branch),
|
|
11324
|
+
tmuxCommand: buildNativeTerminalTmuxCommand(Bun.env),
|
|
11325
|
+
sessionPrefix: `wm-native-${PORT}-`
|
|
11326
|
+
});
|
|
11327
|
+
if (!launch.ok) {
|
|
11328
|
+
return errorResponse(launch.message, launch.reason === "not_found" ? 404 : 409);
|
|
11329
|
+
}
|
|
11330
|
+
return jsonResponse(launch.data);
|
|
11331
|
+
}
|
|
11332
|
+
function getAttachedSessionId(ws) {
|
|
11333
|
+
if (ws.data.attached && ws.data.attachId) {
|
|
11334
|
+
return ws.data.attachId;
|
|
10834
11335
|
}
|
|
10835
11336
|
sendWs(ws, { type: "error", message: "Terminal not attached" });
|
|
10836
11337
|
return null;
|
|
@@ -10864,7 +11365,8 @@ function makeCallbacks(ws) {
|
|
|
10864
11365
|
}
|
|
10865
11366
|
async function apiGetProject() {
|
|
10866
11367
|
touchDashboardActivity();
|
|
10867
|
-
const
|
|
11368
|
+
const linearApiKey = Bun.env.LINEAR_API_KEY;
|
|
11369
|
+
const linearIssuesPromise = config.integrations.linear.enabled && linearApiKey?.trim() ? fetchAssignedIssues() : Promise.resolve({ ok: true, data: [] });
|
|
10868
11370
|
const [, linearResult] = await Promise.all([
|
|
10869
11371
|
reconciliationService.reconcile(PROJECT_DIR),
|
|
10870
11372
|
linearIssuesPromise
|
|
@@ -10899,15 +11401,24 @@ async function apiRuntimeEvent(req) {
|
|
|
10899
11401
|
const event = parseRuntimeEvent(raw);
|
|
10900
11402
|
if (!event)
|
|
10901
11403
|
return errorResponse("Invalid runtime event body", 400);
|
|
10902
|
-
await reconciliationService.reconcile(PROJECT_DIR);
|
|
10903
11404
|
try {
|
|
10904
11405
|
projectRuntime.applyEvent(event);
|
|
10905
11406
|
} catch (error) {
|
|
10906
11407
|
const message = error instanceof Error ? error.message : String(error);
|
|
10907
11408
|
if (message.includes("Unknown worktree id")) {
|
|
10908
|
-
|
|
11409
|
+
await reconciliationService.reconcile(PROJECT_DIR);
|
|
11410
|
+
try {
|
|
11411
|
+
projectRuntime.applyEvent(event);
|
|
11412
|
+
} catch (retryError) {
|
|
11413
|
+
const retryMessage = retryError instanceof Error ? retryError.message : String(retryError);
|
|
11414
|
+
if (retryMessage.includes("Unknown worktree id")) {
|
|
11415
|
+
return errorResponse(retryMessage, 404);
|
|
11416
|
+
}
|
|
11417
|
+
throw retryError;
|
|
11418
|
+
}
|
|
11419
|
+
} else {
|
|
11420
|
+
throw error;
|
|
10909
11421
|
}
|
|
10910
|
-
throw error;
|
|
10911
11422
|
}
|
|
10912
11423
|
const notification = runtimeNotifications.recordEvent(event);
|
|
10913
11424
|
return jsonResponse({
|
|
@@ -10937,21 +11448,56 @@ async function apiCreateWorktree(req) {
|
|
|
10937
11448
|
if (Object.keys(parsed).length > 0)
|
|
10938
11449
|
envOverrides = parsed;
|
|
10939
11450
|
}
|
|
10940
|
-
const branch = typeof body.branch === "string" ? body.branch : undefined;
|
|
10941
|
-
const prompt = typeof body.prompt === "string" ? body.prompt : undefined;
|
|
11451
|
+
const branch = typeof body.branch === "string" && body.branch.trim() ? body.branch.trim() : undefined;
|
|
11452
|
+
const prompt = typeof body.prompt === "string" && body.prompt.trim() ? body.prompt.trim() : undefined;
|
|
10942
11453
|
const profile = typeof body.profile === "string" ? body.profile : undefined;
|
|
10943
11454
|
const agent = body.agent === "claude" || body.agent === "codex" ? body.agent : undefined;
|
|
10944
|
-
const
|
|
10945
|
-
|
|
11455
|
+
const createLinearTicket = body.createLinearTicket === true;
|
|
11456
|
+
const linearTitle = typeof body.linearTitle === "string" && body.linearTitle.trim() ? body.linearTitle.trim() : undefined;
|
|
11457
|
+
const mode = body.mode === "new" || body.mode === "existing" ? body.mode : undefined;
|
|
11458
|
+
if (body.mode !== undefined && body.mode !== "new" && body.mode !== "existing") {
|
|
10946
11459
|
return errorResponse("Invalid worktree create mode", 400);
|
|
10947
11460
|
}
|
|
10948
|
-
if (
|
|
10949
|
-
|
|
11461
|
+
if (createLinearTicket && mode === "existing") {
|
|
11462
|
+
return errorResponse("Linear ticket creation is only supported for new branches", 400);
|
|
11463
|
+
}
|
|
11464
|
+
if (createLinearTicket && !config.integrations.linear.enabled) {
|
|
11465
|
+
return errorResponse("Linear integration is disabled", 400);
|
|
11466
|
+
}
|
|
11467
|
+
if (createLinearTicket && !config.integrations.linear.createTicketOption) {
|
|
11468
|
+
return errorResponse("Linear ticket creation is not enabled for this project", 400);
|
|
11469
|
+
}
|
|
11470
|
+
if (createLinearTicket && !prompt) {
|
|
11471
|
+
return errorResponse("Prompt is required when creating a Linear ticket", 400);
|
|
11472
|
+
}
|
|
11473
|
+
let resolvedBranch = branch;
|
|
11474
|
+
if (createLinearTicket) {
|
|
11475
|
+
const title = deriveLinearIssueTitle(linearTitle, prompt);
|
|
11476
|
+
if (!title) {
|
|
11477
|
+
return errorResponse("Linear ticket title could not be derived from the prompt", 400);
|
|
11478
|
+
}
|
|
11479
|
+
const teamId = config.integrations.linear.teamId;
|
|
11480
|
+
if (!teamId) {
|
|
11481
|
+
return errorResponse("Linear teamId is not configured", 503);
|
|
11482
|
+
}
|
|
11483
|
+
const linearResult = await createLinearIssue({
|
|
11484
|
+
title,
|
|
11485
|
+
description: prompt ?? "",
|
|
11486
|
+
teamId
|
|
11487
|
+
});
|
|
11488
|
+
if (!linearResult.ok) {
|
|
11489
|
+
return errorResponse(linearResult.error, 502);
|
|
11490
|
+
}
|
|
11491
|
+
resolvedBranch = linearResult.data.branchName;
|
|
11492
|
+
ensureBranchNotCreating(resolvedBranch);
|
|
11493
|
+
log.info(`[linear] created ticket ${linearResult.data.identifier} branch=${linearResult.data.branchName} title="${linearResult.data.title.slice(0, 80)}"`);
|
|
11494
|
+
} else if (resolvedBranch) {
|
|
11495
|
+
ensureBranchNotCreating(resolvedBranch);
|
|
10950
11496
|
}
|
|
10951
|
-
log.info(`[worktree:add] mode=${mode ?? "new"}${
|
|
11497
|
+
log.info(`[worktree:add] mode=${mode ?? "new"}${resolvedBranch ? ` branch=${resolvedBranch}` : ""}${profile ? ` profile=${profile}` : ""}${agent ? ` agent=${agent}` : ""}${createLinearTicket ? " linearTicket=true" : ""}${prompt ? ` prompt="${prompt.slice(0, 80)}"` : ""}`);
|
|
10952
11498
|
const result = await lifecycleService.createWorktree({
|
|
10953
11499
|
mode,
|
|
10954
|
-
branch,
|
|
11500
|
+
branch: resolvedBranch,
|
|
10955
11501
|
prompt,
|
|
10956
11502
|
profile,
|
|
10957
11503
|
agent,
|
|
@@ -11007,8 +11553,35 @@ async function apiMergeWorktree(name) {
|
|
|
11007
11553
|
log.debug(`[worktree:merge] done name=${name}`);
|
|
11008
11554
|
return jsonResponse({ ok: true });
|
|
11009
11555
|
}
|
|
11556
|
+
async function apiSetLinearAutoCreate(req) {
|
|
11557
|
+
const raw = await req.json();
|
|
11558
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
11559
|
+
return errorResponse("Invalid request body", 400);
|
|
11560
|
+
}
|
|
11561
|
+
const body = raw;
|
|
11562
|
+
if (typeof body.enabled !== "boolean") {
|
|
11563
|
+
return errorResponse("Missing boolean 'enabled' field", 400);
|
|
11564
|
+
}
|
|
11565
|
+
linearAutoCreateEnabled = body.enabled;
|
|
11566
|
+
if (linearAutoCreateEnabled) {
|
|
11567
|
+
resetProcessedIssues();
|
|
11568
|
+
startLinearAutoCreate();
|
|
11569
|
+
log.info("[config] Linear auto-create worktrees enabled");
|
|
11570
|
+
} else {
|
|
11571
|
+
stopLinearAutoCreateMonitor();
|
|
11572
|
+
log.info("[config] Linear auto-create worktrees disabled");
|
|
11573
|
+
}
|
|
11574
|
+
await persistLocalLinearConfig(PROJECT_DIR, { autoCreateWorktrees: linearAutoCreateEnabled });
|
|
11575
|
+
return jsonResponse({ ok: true, enabled: linearAutoCreateEnabled });
|
|
11576
|
+
}
|
|
11010
11577
|
async function apiGetLinearIssues() {
|
|
11011
|
-
const
|
|
11578
|
+
const apiKey = Bun.env.LINEAR_API_KEY;
|
|
11579
|
+
const fetchResult = config.integrations.linear.enabled && apiKey?.trim() ? await fetchAssignedIssues() : undefined;
|
|
11580
|
+
const result = buildLinearIssuesResponse({
|
|
11581
|
+
integrationEnabled: config.integrations.linear.enabled,
|
|
11582
|
+
apiKey,
|
|
11583
|
+
fetchResult
|
|
11584
|
+
});
|
|
11012
11585
|
if (!result.ok)
|
|
11013
11586
|
return errorResponse(result.error, 502);
|
|
11014
11587
|
return jsonResponse(result.data);
|
|
@@ -11020,18 +11593,12 @@ async function apiGetWorktreeDiff(name) {
|
|
|
11020
11593
|
if (!state)
|
|
11021
11594
|
return errorResponse(`Worktree not found: ${name}`, 404);
|
|
11022
11595
|
const uncommitted = git.readDiff(state.path);
|
|
11023
|
-
const
|
|
11024
|
-
|
|
11025
|
-
const truncated = raw.length > MAX_DIFF_BYTES;
|
|
11026
|
-
return { diff: truncated ? raw.slice(0, MAX_DIFF_BYTES) : raw, truncated };
|
|
11027
|
-
}
|
|
11028
|
-
const u = cap(uncommitted);
|
|
11029
|
-
const p = cap(unpushed);
|
|
11596
|
+
const unpushedCommits = git.listUnpushedCommits(state.path);
|
|
11597
|
+
const truncated = uncommitted.length > MAX_DIFF_BYTES;
|
|
11030
11598
|
return jsonResponse({
|
|
11031
|
-
uncommitted:
|
|
11032
|
-
uncommittedTruncated:
|
|
11033
|
-
|
|
11034
|
-
unpushedTruncated: p.truncated
|
|
11599
|
+
uncommitted: truncated ? uncommitted.slice(0, MAX_DIFF_BYTES) : uncommitted,
|
|
11600
|
+
uncommittedTruncated: truncated,
|
|
11601
|
+
unpushedCommits
|
|
11035
11602
|
});
|
|
11036
11603
|
}
|
|
11037
11604
|
async function apiCiLogs(runId) {
|
|
@@ -11102,7 +11669,7 @@ Bun.serve({
|
|
|
11102
11669
|
routes: {
|
|
11103
11670
|
"/ws/:worktree": (req, server) => {
|
|
11104
11671
|
const branch = decodeURIComponent(req.params.worktree);
|
|
11105
|
-
return server.upgrade(req, { data: { branch, worktreeId: null, attached: false } }) ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
|
|
11672
|
+
return server.upgrade(req, { data: { branch, worktreeId: null, attachId: null, attached: false } }) ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
|
|
11106
11673
|
},
|
|
11107
11674
|
"/api/config": {
|
|
11108
11675
|
GET: () => jsonResponse(getFrontendConfig())
|
|
@@ -11135,6 +11702,14 @@ Bun.serve({
|
|
|
11135
11702
|
return catching(`POST /api/worktrees/${name}/open`, () => apiOpenWorktree(name));
|
|
11136
11703
|
}
|
|
11137
11704
|
},
|
|
11705
|
+
"/api/worktrees/:name/terminal-launch": {
|
|
11706
|
+
GET: (req) => {
|
|
11707
|
+
const name = decodeURIComponent(req.params.name);
|
|
11708
|
+
if (!isValidWorktreeName(name))
|
|
11709
|
+
return errorResponse("Invalid worktree name", 400);
|
|
11710
|
+
return catching(`GET /api/worktrees/${name}/terminal-launch`, () => apiGetNativeTerminalLaunch(name));
|
|
11711
|
+
}
|
|
11712
|
+
},
|
|
11138
11713
|
"/api/worktrees/:name/close": {
|
|
11139
11714
|
POST: (req) => {
|
|
11140
11715
|
const name = decodeURIComponent(req.params.name);
|
|
@@ -11178,6 +11753,9 @@ Bun.serve({
|
|
|
11178
11753
|
"/api/linear/issues": {
|
|
11179
11754
|
GET: () => catching("GET /api/linear/issues", () => apiGetLinearIssues())
|
|
11180
11755
|
},
|
|
11756
|
+
"/api/linear/auto-create": {
|
|
11757
|
+
PUT: (req) => catching("PUT /api/linear/auto-create", () => apiSetLinearAutoCreate(req))
|
|
11758
|
+
},
|
|
11181
11759
|
"/api/ci-logs/:runId": {
|
|
11182
11760
|
GET: (req) => catching(`GET /api/ci-logs/${req.params.runId}`, () => apiCiLogs(req.params.runId))
|
|
11183
11761
|
},
|
|
@@ -11231,26 +11809,26 @@ Bun.serve({
|
|
|
11231
11809
|
const { branch } = ws.data;
|
|
11232
11810
|
switch (msg.type) {
|
|
11233
11811
|
case "input": {
|
|
11234
|
-
const
|
|
11235
|
-
if (!
|
|
11812
|
+
const attachId = getAttachedSessionId(ws);
|
|
11813
|
+
if (!attachId)
|
|
11236
11814
|
return;
|
|
11237
|
-
write(
|
|
11815
|
+
write(attachId, msg.data);
|
|
11238
11816
|
break;
|
|
11239
11817
|
}
|
|
11240
11818
|
case "sendKeys": {
|
|
11241
|
-
const
|
|
11242
|
-
if (!
|
|
11819
|
+
const attachId = getAttachedSessionId(ws);
|
|
11820
|
+
if (!attachId)
|
|
11243
11821
|
return;
|
|
11244
|
-
await sendKeys(
|
|
11822
|
+
await sendKeys(attachId, msg.hexBytes);
|
|
11245
11823
|
break;
|
|
11246
11824
|
}
|
|
11247
11825
|
case "selectPane":
|
|
11248
11826
|
{
|
|
11249
|
-
const
|
|
11250
|
-
if (!
|
|
11827
|
+
const attachId = getAttachedSessionId(ws);
|
|
11828
|
+
if (!attachId)
|
|
11251
11829
|
return;
|
|
11252
|
-
log.debug(`[ws] selectPane pane=${msg.pane} branch=${branch}
|
|
11253
|
-
await selectPane(
|
|
11830
|
+
log.debug(`[ws] selectPane pane=${msg.pane} branch=${branch} attachId=${attachId}`);
|
|
11831
|
+
await selectPane(attachId, msg.pane);
|
|
11254
11832
|
}
|
|
11255
11833
|
break;
|
|
11256
11834
|
case "resize":
|
|
@@ -11262,12 +11840,14 @@ Bun.serve({
|
|
|
11262
11840
|
log.debug(`[ws] initialPane=${msg.initialPane} branch=${branch}`);
|
|
11263
11841
|
}
|
|
11264
11842
|
const terminalWorktree = await resolveTerminalWorktree(branch);
|
|
11843
|
+
const attachId = `${terminalWorktree.worktreeId}:${randomUUID3()}`;
|
|
11265
11844
|
ws.data.worktreeId = terminalWorktree.worktreeId;
|
|
11266
|
-
|
|
11845
|
+
ws.data.attachId = attachId;
|
|
11846
|
+
await attach(attachId, terminalWorktree.attachTarget, msg.cols, msg.rows, msg.initialPane);
|
|
11267
11847
|
const { onData, onExit } = makeCallbacks(ws);
|
|
11268
|
-
setCallbacks(
|
|
11269
|
-
const scrollback = getScrollback(
|
|
11270
|
-
log.debug(`[ws] attached branch=${branch} worktreeId=${terminalWorktree.worktreeId} scrollback=${scrollback.length} bytes`);
|
|
11848
|
+
setCallbacks(attachId, onData, onExit);
|
|
11849
|
+
const scrollback = getScrollback(attachId);
|
|
11850
|
+
log.debug(`[ws] attached branch=${branch} worktreeId=${terminalWorktree.worktreeId} attachId=${attachId} scrollback=${scrollback.length} bytes`);
|
|
11271
11851
|
if (scrollback.length > 0) {
|
|
11272
11852
|
sendWs(ws, { type: "scrollback", data: scrollback });
|
|
11273
11853
|
}
|
|
@@ -11275,24 +11855,25 @@ Bun.serve({
|
|
|
11275
11855
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
11276
11856
|
ws.data.attached = false;
|
|
11277
11857
|
ws.data.worktreeId = null;
|
|
11858
|
+
ws.data.attachId = null;
|
|
11278
11859
|
log.error(`[ws] attach failed branch=${branch}: ${errMsg}`);
|
|
11279
11860
|
sendWs(ws, { type: "error", message: errMsg });
|
|
11280
11861
|
ws.close(1011, errMsg.slice(0, 123));
|
|
11281
11862
|
}
|
|
11282
11863
|
} else {
|
|
11283
|
-
const
|
|
11284
|
-
if (!
|
|
11864
|
+
const attachId = getAttachedSessionId(ws);
|
|
11865
|
+
if (!attachId)
|
|
11285
11866
|
return;
|
|
11286
|
-
await resize(
|
|
11867
|
+
await resize(attachId, msg.cols, msg.rows);
|
|
11287
11868
|
}
|
|
11288
11869
|
break;
|
|
11289
11870
|
}
|
|
11290
11871
|
},
|
|
11291
11872
|
async close(ws, code, reason) {
|
|
11292
|
-
log.debug(`[ws] close branch=${ws.data.branch} code=${code} reason=${reason} attached=${ws.data.attached} worktreeId=${ws.data.worktreeId}`);
|
|
11293
|
-
if (ws.data.
|
|
11294
|
-
clearCallbacks(ws.data.
|
|
11295
|
-
await detach(ws.data.
|
|
11873
|
+
log.debug(`[ws] close branch=${ws.data.branch} code=${code} reason=${reason} attached=${ws.data.attached} worktreeId=${ws.data.worktreeId} attachId=${ws.data.attachId}`);
|
|
11874
|
+
if (ws.data.attachId) {
|
|
11875
|
+
clearCallbacks(ws.data.attachId);
|
|
11876
|
+
await detach(ws.data.attachId);
|
|
11296
11877
|
}
|
|
11297
11878
|
}
|
|
11298
11879
|
}
|
|
@@ -11304,6 +11885,9 @@ if (tmuxCheck.exitCode !== 0) {
|
|
|
11304
11885
|
}
|
|
11305
11886
|
cleanupStaleSessions();
|
|
11306
11887
|
startPrMonitor(getWorktreeGitDirs, config.integrations.github.linkedRepos, PROJECT_DIR, undefined, hasRecentDashboardActivity);
|
|
11888
|
+
if (linearAutoCreateEnabled) {
|
|
11889
|
+
startLinearAutoCreate();
|
|
11890
|
+
}
|
|
11307
11891
|
log.info(`Dev Dashboard API running at http://localhost:${PORT}`);
|
|
11308
11892
|
var nets = networkInterfaces();
|
|
11309
11893
|
for (const addrs of Object.values(nets)) {
|