webmux 0.18.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.
@@ -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(worktreeId, target, cols, rows, initialPane) {
7001
- log.debug(`[term] attach(${worktreeId}) cols=${cols} rows=${rows} existing=${sessions.has(worktreeId)}`);
7002
- if (sessions.has(worktreeId)) {
7003
- await detach(worktreeId);
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(${worktreeId}) ownerSession=${target.ownerSessionName} gName=${gName} window=${target.windowName}`);
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(worktreeId, session);
7034
- log.debug(`[term] attach(${worktreeId}) spawned pid=${proc.pid}`);
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(${worktreeId})`, err);
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(${worktreeId}): ${textDecoder.decode(value).trimEnd()}`);
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(${worktreeId}) pid=${proc.pid} code=${exitCode}`);
7072
- if (sessions.get(worktreeId) === session) {
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(worktreeId);
7075
+ sessions.delete(attachId);
7075
7076
  } else {
7076
- log.debug(`[term] proc exited(${worktreeId}) stale session, skipping cleanup`);
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(worktreeId) {
7082
- const session = sessions.get(worktreeId);
7082
+ async function detach(attachId) {
7083
+ const session = sessions.get(attachId);
7083
7084
  if (!session) {
7084
- log.debug(`[term] detach(${worktreeId}) no session found`);
7085
+ log.debug(`[term] detach(${attachId}) no session found`);
7085
7086
  return;
7086
7087
  }
7087
- log.debug(`[term] detach(${worktreeId}) killing pid=${session.proc.pid} tmux=${session.groupedSessionName}`);
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(worktreeId);
7091
+ sessions.delete(attachId);
7091
7092
  killTmuxSession(session.groupedSessionName);
7092
7093
  }
7093
- function write(worktreeId, data) {
7094
- const session = sessions.get(worktreeId);
7094
+ function write(attachId, data) {
7095
+ const session = sessions.get(attachId);
7095
7096
  if (!session) {
7096
- log.warn(`[term] write(${worktreeId}) NO SESSION - input dropped (${data.length} bytes)`);
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(${worktreeId}) stdin closed`, err);
7104
+ log.error(`[term] write(${attachId}) stdin closed`, err);
7104
7105
  }
7105
7106
  }
7106
- async function sendKeys(worktreeId, hexBytes) {
7107
- const session = sessions.get(worktreeId);
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(worktreeId, cols, rows) {
7114
- const session = sessions.get(worktreeId);
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(worktreeId) {
7123
- return sessions.get(worktreeId)?.scrollback.join("") ?? "";
7123
+ function getScrollback(attachId) {
7124
+ return sessions.get(attachId)?.scrollback.join("") ?? "";
7124
7125
  }
7125
- function setCallbacks(worktreeId, onData, onExit) {
7126
- const session = sessions.get(worktreeId);
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(worktreeId, paneIndex) {
7133
- const session = sessions.get(worktreeId);
7133
+ async function selectPane(attachId, paneIndex) {
7134
+ const session = sessions.get(attachId);
7134
7135
  if (!session) {
7135
- log.debug(`[term] selectPane(${worktreeId}) no session found`);
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(${worktreeId}) pane=${paneIndex} target=${target}`);
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(${worktreeId}) select=${r1.exitCode} zoom=${r2.exitCode}`);
7145
+ log.debug(`[term] selectPane(${attachId}) select=${r1.exitCode} zoom=${r2.exitCode}`);
7145
7146
  }
7146
- function clearCallbacks(worktreeId) {
7147
- const session = sessions.get(worktreeId);
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
- if (raw.errors && raw.errors.length > 0) {
7606
- return { ok: false, error: raw.errors.map((e) => e.message).join("; ") };
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 nodes = raw.data.viewer.assignedIssues.nodes;
7612
- const issues = nodes.map((n) => ({
7613
- id: n.id,
7614
- identifier: n.identifier,
7615
- title: n.title,
7616
- description: n.description,
7617
- priority: n.priority,
7618
- priorityLabel: n.priorityLabel,
7619
- url: n.url,
7620
- branchName: n.branchName,
7621
- dueDate: n.dueDate,
7622
- updatedAt: n.updatedAt,
7623
- state: n.state,
7624
- team: n.team,
7625
- labels: n.labels.nodes,
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
- async function fetchAssignedIssues() {
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: ASSIGNED_ISSUES_QUERY })
7845
+ body: JSON.stringify(variables ? { query, variables } : { query })
7670
7846
  });
7671
7847
  if (!res.ok) {
7672
7848
  const text = await res.text();
7673
- const result2 = { ok: false, error: `Linear API ${res.status}: ${text.slice(0, 200)}` };
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 result;
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";
@@ -8897,7 +9151,7 @@ class LifecycleService {
8897
9151
  agent,
8898
9152
  phase: "reconciling"
8899
9153
  });
8900
- await this.deps.reconciliation.reconcile(this.deps.projectRoot);
9154
+ await this.deps.reconciliation.reconcile(this.deps.projectRoot, { force: true });
8901
9155
  return {
8902
9156
  branch,
8903
9157
  worktreeId: initialized.meta.worktreeId
@@ -8932,7 +9186,7 @@ class LifecycleService {
8932
9186
  worktreePath: resolved.entry.path,
8933
9187
  launchMode
8934
9188
  });
8935
- await this.deps.reconciliation.reconcile(this.deps.projectRoot);
9189
+ await this.deps.reconciliation.reconcile(this.deps.projectRoot, { force: true });
8936
9190
  return {
8937
9191
  branch,
8938
9192
  worktreeId: initialized.meta.worktreeId
@@ -8945,7 +9199,7 @@ class LifecycleService {
8945
9199
  try {
8946
9200
  const resolved = await this.resolveExistingWorktree(branch);
8947
9201
  this.deps.tmux.killWindow(buildProjectSessionName(this.deps.projectRoot), buildWorktreeWindowName(branch));
8948
- await this.deps.reconciliation.reconcile(this.deps.projectRoot);
9202
+ await this.deps.reconciliation.reconcile(this.deps.projectRoot, { force: true });
8949
9203
  } catch (error) {
8950
9204
  throw this.wrapOperationError(error);
8951
9205
  }
@@ -9271,15 +9525,15 @@ class LifecycleService {
9271
9525
  deleteBranch: true,
9272
9526
  deleteBranchForce: true
9273
9527
  }, this.deps.git);
9274
- await this.deps.reconciliation.reconcile(this.deps.projectRoot);
9528
+ await this.deps.reconciliation.reconcile(this.deps.projectRoot, { force: true });
9275
9529
  }
9276
9530
  async runLifecycleHook(input) {
9277
- console.debug(`[lifecycle-hook] name=${input.name} command=${input.command ?? "UNDEFINED"} meta=${input.meta ? "present" : "NULL"} cwd=${input.worktreePath}`);
9531
+ log.debug(`[lifecycle-hook] name=${input.name} command=${input.command ?? "UNDEFINED"} meta=${input.meta ? "present" : "NULL"} cwd=${input.worktreePath}`);
9278
9532
  if (!input.command || !input.meta) {
9279
- console.debug(`[lifecycle-hook] SKIPPING ${input.name}: command=${!!input.command} meta=${!!input.meta}`);
9533
+ log.debug(`[lifecycle-hook] SKIPPING ${input.name}: command=${!!input.command} meta=${!!input.meta}`);
9280
9534
  return;
9281
9535
  }
9282
- console.debug(`[lifecycle-hook] RUNNING ${input.name}: ${input.command} in ${input.worktreePath}`);
9536
+ log.debug(`[lifecycle-hook] RUNNING ${input.name}: ${input.command} in ${input.worktreePath}`);
9283
9537
  const dotenvValues = await loadDotenvLocal(input.worktreePath);
9284
9538
  await this.deps.hooks.run({
9285
9539
  name: input.name,
@@ -9289,7 +9543,7 @@ class LifecycleService {
9289
9543
  WEBMUX_WORKTREE_PATH: input.worktreePath
9290
9544
  }, dotenvValues)
9291
9545
  });
9292
- console.debug(`[lifecycle-hook] COMPLETED ${input.name}`);
9546
+ log.debug(`[lifecycle-hook] COMPLETED ${input.name}`);
9293
9547
  }
9294
9548
  async reportCreateProgress(progress) {
9295
9549
  await this.deps.onCreateProgress?.(progress);
@@ -9305,6 +9559,117 @@ class LifecycleService {
9305
9559
  }
9306
9560
  }
9307
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
+
9308
9673
  // backend/src/services/pr-service.ts
9309
9674
  var PR_FETCH_LIMIT = 50;
9310
9675
  var GH_TIMEOUT_MS = 15000;
@@ -9424,18 +9789,6 @@ async function fetchAllPrs(repoSlug, repoLabel, cwd) {
9424
9789
  return { ok: false, error: `failed to parse gh output for ${label}: ${err}` };
9425
9790
  }
9426
9791
  }
9427
- async function mapWithConcurrency(items, limit, fn) {
9428
- const results = new Array(items.length);
9429
- let next = 0;
9430
- async function worker() {
9431
- while (next < items.length) {
9432
- const idx = next++;
9433
- results[idx] = await fn(items[idx]);
9434
- }
9435
- }
9436
- await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
9437
- return results;
9438
- }
9439
9792
  async function fetchReviewComments(prNumber, repoSlug, cwd) {
9440
9793
  const repoFlag = repoSlug ? repoSlug : "{owner}/{repo}";
9441
9794
  const apiPath = `repos/${repoFlag}/pulls/${prNumber}/comments?per_page=100`;
@@ -9621,21 +9974,76 @@ async function syncPrStatus(getWorktreeGitDirs, linkedRepos, projectDir) {
9621
9974
  etagCache.delete(key);
9622
9975
  }
9623
9976
  }
9624
- function startPrMonitor(getWorktreeGitDirs, linkedRepos, projectDir, intervalMs = 20000, isActive) {
9625
- const run = () => {
9977
+ function startPrMonitor(getWorktreeGitDirs, linkedRepos, projectDir, intervalMs = 1e4, isActive) {
9978
+ const run = async () => {
9626
9979
  if (isActive && !isActive()) {
9627
9980
  log.debug("[pr] skipping PR sync: no active clients");
9628
9981
  return;
9629
9982
  }
9630
- syncPrStatus(getWorktreeGitDirs, linkedRepos, projectDir).catch((err) => {
9983
+ await syncPrStatus(getWorktreeGitDirs, linkedRepos, projectDir).catch((err) => {
9631
9984
  log.error(`[pr] sync error: ${err}`);
9632
9985
  });
9633
9986
  };
9634
- run();
9635
- const timer = setInterval(run, intervalMs);
9636
- return () => {
9637
- clearInterval(timer);
9638
- };
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();
9639
10047
  }
9640
10048
 
9641
10049
  // backend/src/services/snapshot-service.ts
@@ -10058,8 +10466,8 @@ class BunLifecycleHookRunner {
10058
10466
  }
10059
10467
  async run(input) {
10060
10468
  const cmd = await this.buildCommand(input.cwd, input.command);
10061
- console.debug(`[hook-runner] Spawning: ${cmd.join(" ")} cwd=${input.cwd}`);
10062
- console.debug(`[hook-runner] Env keys: ${Object.keys(input.env).join(", ")}`);
10469
+ log.debug(`[hook-runner] Spawning: ${cmd.join(" ")} cwd=${input.cwd}`);
10470
+ log.debug(`[hook-runner] envKeys=${Object.keys(input.env).length}`);
10063
10471
  const proc = Bun.spawn(cmd, {
10064
10472
  cwd: input.cwd,
10065
10473
  env: {
@@ -10074,11 +10482,11 @@ class BunLifecycleHookRunner {
10074
10482
  new Response(proc.stdout).text(),
10075
10483
  new Response(proc.stderr).text()
10076
10484
  ]);
10077
- console.debug(`[hook-runner] ${input.name} exitCode=${exitCode}`);
10485
+ log.debug(`[hook-runner] ${input.name} exitCode=${exitCode}`);
10078
10486
  if (stdout.trim())
10079
- console.debug(`[hook-runner] stdout: ${stdout.trim()}`);
10487
+ log.debug(`[hook-runner] stdout: ${stdout.trim()}`);
10080
10488
  if (stderr.trim())
10081
- console.debug(`[hook-runner] stderr: ${stderr.trim()}`);
10489
+ log.debug(`[hook-runner] stderr: ${stderr.trim()}`);
10082
10490
  if (exitCode !== 0) {
10083
10491
  throw new Error(buildErrorMessage(input.name, exitCode, stdout, stderr));
10084
10492
  }
@@ -10562,11 +10970,34 @@ function resolveBranch(entry, metaBranch) {
10562
10970
 
10563
10971
  class ReconciliationService {
10564
10972
  deps;
10565
- constructor(deps) {
10973
+ freshnessMs;
10974
+ now;
10975
+ concurrency;
10976
+ inFlight = null;
10977
+ lastReconciledAt = 0;
10978
+ constructor(deps, options = {}) {
10566
10979
  this.deps = deps;
10980
+ this.freshnessMs = options.freshnessMs ?? 500;
10981
+ this.now = options.now ?? Date.now;
10982
+ this.concurrency = options.concurrency ?? 4;
10567
10983
  }
10568
- 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
+ }
10569
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) {
10570
11001
  const worktrees = this.deps.git.listWorktrees(normalizedRepoRoot);
10571
11002
  const sessionName = buildProjectSessionName(normalizedRepoRoot);
10572
11003
  let windows = [];
@@ -10576,40 +11007,32 @@ class ReconciliationService {
10576
11007
  windows = [];
10577
11008
  }
10578
11009
  const seenWorktreeIds = new Set;
10579
- for (const entry of worktrees) {
10580
- if (entry.bare)
10581
- continue;
10582
- if (resolve5(entry.path) === normalizedRepoRoot)
10583
- continue;
11010
+ const candidateEntries = worktrees.filter((entry) => !entry.bare && resolve5(entry.path) !== normalizedRepoRoot);
11011
+ const reconciledStates = await mapWithConcurrency(candidateEntries, this.concurrency, async (entry) => {
10584
11012
  const gitDir = this.deps.git.resolveWorktreeGitDir(entry.path);
10585
11013
  const meta = await readWorktreeMeta(gitDir);
10586
11014
  const branch = resolveBranch(entry, meta?.branch ?? null);
10587
11015
  const worktreeId = meta?.worktreeId ?? makeUnmanagedWorktreeId(entry.path);
10588
- seenWorktreeIds.add(worktreeId);
10589
- this.deps.runtime.upsertWorktree({
11016
+ const gitStatus = this.deps.git.readWorktreeStatus(entry.path);
11017
+ const window = findWindow(windows, sessionName, branch);
11018
+ return {
10590
11019
  worktreeId,
10591
11020
  branch,
10592
11021
  path: entry.path,
10593
11022
  profile: meta?.profile ?? null,
10594
11023
  agentName: meta?.agent ?? null,
10595
- runtime: meta?.runtime ?? "host"
10596
- });
10597
- const gitStatus = this.deps.git.readWorktreeStatus(entry.path);
10598
- this.deps.runtime.setGitState(worktreeId, {
10599
- exists: true,
10600
- branch,
10601
- dirty: gitStatus.dirty,
10602
- aheadCount: gitStatus.aheadCount,
10603
- currentCommit: gitStatus.currentCommit
10604
- });
10605
- const window = findWindow(windows, sessionName, branch);
10606
- this.deps.runtime.setSessionState(worktreeId, {
10607
- exists: window !== null,
10608
- sessionName: window?.sessionName ?? null,
10609
- paneCount: window?.paneCount ?? 0
10610
- });
10611
- if (meta) {
10612
- 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, {
10613
11036
  allocatedPorts: meta.allocatedPorts,
10614
11037
  startupEnvValues: meta.startupEnvValues,
10615
11038
  worktreeId: meta.worktreeId,
@@ -10617,11 +11040,34 @@ class ReconciliationService {
10617
11040
  profile: meta.profile,
10618
11041
  agent: meta.agent,
10619
11042
  runtime: meta.runtime
10620
- }));
10621
- } else {
10622
- this.deps.runtime.setServices(worktreeId, []);
10623
- }
10624
- this.deps.runtime.setPrs(worktreeId, await readWorktreePrs(gitDir));
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);
10625
11071
  }
10626
11072
  for (const state of this.deps.runtime.listWorktrees()) {
10627
11073
  if (!seenWorktreeIds.has(state.worktreeId)) {
@@ -10729,6 +11175,24 @@ var runtimeNotifications = runtime.runtimeNotifications;
10729
11175
  var reconciliationService = runtime.reconciliationService;
10730
11176
  var removingBranches = new Set;
10731
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
+ }
10732
11196
  function getFrontendConfig() {
10733
11197
  const defaultProfileName = getDefaultProfileName(config);
10734
11198
  const orderedProfileEntries = Object.entries(config.profiles).sort(([left], [right]) => {
@@ -10747,11 +11211,13 @@ function getFrontendConfig() {
10747
11211
  })),
10748
11212
  defaultProfileName,
10749
11213
  autoName: config.autoName !== null,
11214
+ linearCreateTicketOption: config.integrations.linear.enabled && config.integrations.linear.createTicketOption,
10750
11215
  startupEnvs: config.startupEnvs,
10751
11216
  linkedRepos: config.integrations.github.linkedRepos.map((lr) => ({
10752
11217
  alias: lr.alias,
10753
11218
  ...lr.dir ? { dir: resolve6(PROJECT_DIR, lr.dir) } : {}
10754
- }))
11219
+ })),
11220
+ linearAutoCreateWorktrees: linearAutoCreateEnabled
10755
11221
  };
10756
11222
  }
10757
11223
  function parseWsMessage(raw) {
@@ -10829,8 +11295,11 @@ async function withRemovingBranch(branch, fn) {
10829
11295
  }
10830
11296
  async function resolveTerminalWorktree(branch) {
10831
11297
  ensureBranchNotBusy(branch);
10832
- await reconciliationService.reconcile(PROJECT_DIR);
10833
- const state = projectRuntime.getWorktreeByBranch(branch);
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
+ }
10834
11303
  if (!state) {
10835
11304
  throw new Error(`Worktree not found: ${branch}`);
10836
11305
  }
@@ -10845,9 +11314,24 @@ async function resolveTerminalWorktree(branch) {
10845
11314
  }
10846
11315
  };
10847
11316
  }
10848
- function getAttachedWorktreeId(ws) {
10849
- if (ws.data.attached && ws.data.worktreeId) {
10850
- return ws.data.worktreeId;
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;
10851
11335
  }
10852
11336
  sendWs(ws, { type: "error", message: "Terminal not attached" });
10853
11337
  return null;
@@ -10881,7 +11365,8 @@ function makeCallbacks(ws) {
10881
11365
  }
10882
11366
  async function apiGetProject() {
10883
11367
  touchDashboardActivity();
10884
- const linearIssuesPromise = config.integrations.linear.enabled ? fetchAssignedIssues() : Promise.resolve({ ok: true, data: [] });
11368
+ const linearApiKey = Bun.env.LINEAR_API_KEY;
11369
+ const linearIssuesPromise = config.integrations.linear.enabled && linearApiKey?.trim() ? fetchAssignedIssues() : Promise.resolve({ ok: true, data: [] });
10885
11370
  const [, linearResult] = await Promise.all([
10886
11371
  reconciliationService.reconcile(PROJECT_DIR),
10887
11372
  linearIssuesPromise
@@ -10916,15 +11401,24 @@ async function apiRuntimeEvent(req) {
10916
11401
  const event = parseRuntimeEvent(raw);
10917
11402
  if (!event)
10918
11403
  return errorResponse("Invalid runtime event body", 400);
10919
- await reconciliationService.reconcile(PROJECT_DIR);
10920
11404
  try {
10921
11405
  projectRuntime.applyEvent(event);
10922
11406
  } catch (error) {
10923
11407
  const message = error instanceof Error ? error.message : String(error);
10924
11408
  if (message.includes("Unknown worktree id")) {
10925
- return errorResponse(message, 404);
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;
10926
11421
  }
10927
- throw error;
10928
11422
  }
10929
11423
  const notification = runtimeNotifications.recordEvent(event);
10930
11424
  return jsonResponse({
@@ -10954,21 +11448,56 @@ async function apiCreateWorktree(req) {
10954
11448
  if (Object.keys(parsed).length > 0)
10955
11449
  envOverrides = parsed;
10956
11450
  }
10957
- const branch = typeof body.branch === "string" ? body.branch : undefined;
10958
- 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;
10959
11453
  const profile = typeof body.profile === "string" ? body.profile : undefined;
10960
11454
  const agent = body.agent === "claude" || body.agent === "codex" ? body.agent : undefined;
10961
- const mode = body.mode;
10962
- if (mode !== undefined && mode !== "new" && mode !== "existing") {
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") {
10963
11459
  return errorResponse("Invalid worktree create mode", 400);
10964
11460
  }
10965
- if (branch) {
10966
- ensureBranchNotCreating(branch);
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);
10967
11496
  }
10968
- log.info(`[worktree:add] mode=${mode ?? "new"}${branch ? ` branch=${branch}` : ""}${profile ? ` profile=${profile}` : ""}${agent ? ` agent=${agent}` : ""}${prompt ? ` prompt="${prompt.slice(0, 80)}"` : ""}`);
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)}"` : ""}`);
10969
11498
  const result = await lifecycleService.createWorktree({
10970
11499
  mode,
10971
- branch,
11500
+ branch: resolvedBranch,
10972
11501
  prompt,
10973
11502
  profile,
10974
11503
  agent,
@@ -11024,8 +11553,35 @@ async function apiMergeWorktree(name) {
11024
11553
  log.debug(`[worktree:merge] done name=${name}`);
11025
11554
  return jsonResponse({ ok: true });
11026
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
+ }
11027
11577
  async function apiGetLinearIssues() {
11028
- const result = await fetchAssignedIssues();
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
+ });
11029
11585
  if (!result.ok)
11030
11586
  return errorResponse(result.error, 502);
11031
11587
  return jsonResponse(result.data);
@@ -11113,7 +11669,7 @@ Bun.serve({
11113
11669
  routes: {
11114
11670
  "/ws/:worktree": (req, server) => {
11115
11671
  const branch = decodeURIComponent(req.params.worktree);
11116
- 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 });
11117
11673
  },
11118
11674
  "/api/config": {
11119
11675
  GET: () => jsonResponse(getFrontendConfig())
@@ -11146,6 +11702,14 @@ Bun.serve({
11146
11702
  return catching(`POST /api/worktrees/${name}/open`, () => apiOpenWorktree(name));
11147
11703
  }
11148
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
+ },
11149
11713
  "/api/worktrees/:name/close": {
11150
11714
  POST: (req) => {
11151
11715
  const name = decodeURIComponent(req.params.name);
@@ -11189,6 +11753,9 @@ Bun.serve({
11189
11753
  "/api/linear/issues": {
11190
11754
  GET: () => catching("GET /api/linear/issues", () => apiGetLinearIssues())
11191
11755
  },
11756
+ "/api/linear/auto-create": {
11757
+ PUT: (req) => catching("PUT /api/linear/auto-create", () => apiSetLinearAutoCreate(req))
11758
+ },
11192
11759
  "/api/ci-logs/:runId": {
11193
11760
  GET: (req) => catching(`GET /api/ci-logs/${req.params.runId}`, () => apiCiLogs(req.params.runId))
11194
11761
  },
@@ -11242,26 +11809,26 @@ Bun.serve({
11242
11809
  const { branch } = ws.data;
11243
11810
  switch (msg.type) {
11244
11811
  case "input": {
11245
- const worktreeId = getAttachedWorktreeId(ws);
11246
- if (!worktreeId)
11812
+ const attachId = getAttachedSessionId(ws);
11813
+ if (!attachId)
11247
11814
  return;
11248
- write(worktreeId, msg.data);
11815
+ write(attachId, msg.data);
11249
11816
  break;
11250
11817
  }
11251
11818
  case "sendKeys": {
11252
- const worktreeId = getAttachedWorktreeId(ws);
11253
- if (!worktreeId)
11819
+ const attachId = getAttachedSessionId(ws);
11820
+ if (!attachId)
11254
11821
  return;
11255
- await sendKeys(worktreeId, msg.hexBytes);
11822
+ await sendKeys(attachId, msg.hexBytes);
11256
11823
  break;
11257
11824
  }
11258
11825
  case "selectPane":
11259
11826
  {
11260
- const worktreeId = getAttachedWorktreeId(ws);
11261
- if (!worktreeId)
11827
+ const attachId = getAttachedSessionId(ws);
11828
+ if (!attachId)
11262
11829
  return;
11263
- log.debug(`[ws] selectPane pane=${msg.pane} branch=${branch} worktreeId=${worktreeId}`);
11264
- await selectPane(worktreeId, msg.pane);
11830
+ log.debug(`[ws] selectPane pane=${msg.pane} branch=${branch} attachId=${attachId}`);
11831
+ await selectPane(attachId, msg.pane);
11265
11832
  }
11266
11833
  break;
11267
11834
  case "resize":
@@ -11273,12 +11840,14 @@ Bun.serve({
11273
11840
  log.debug(`[ws] initialPane=${msg.initialPane} branch=${branch}`);
11274
11841
  }
11275
11842
  const terminalWorktree = await resolveTerminalWorktree(branch);
11843
+ const attachId = `${terminalWorktree.worktreeId}:${randomUUID3()}`;
11276
11844
  ws.data.worktreeId = terminalWorktree.worktreeId;
11277
- await attach(terminalWorktree.worktreeId, terminalWorktree.attachTarget, msg.cols, msg.rows, msg.initialPane);
11845
+ ws.data.attachId = attachId;
11846
+ await attach(attachId, terminalWorktree.attachTarget, msg.cols, msg.rows, msg.initialPane);
11278
11847
  const { onData, onExit } = makeCallbacks(ws);
11279
- setCallbacks(terminalWorktree.worktreeId, onData, onExit);
11280
- const scrollback = getScrollback(terminalWorktree.worktreeId);
11281
- 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`);
11282
11851
  if (scrollback.length > 0) {
11283
11852
  sendWs(ws, { type: "scrollback", data: scrollback });
11284
11853
  }
@@ -11286,24 +11855,25 @@ Bun.serve({
11286
11855
  const errMsg = err instanceof Error ? err.message : String(err);
11287
11856
  ws.data.attached = false;
11288
11857
  ws.data.worktreeId = null;
11858
+ ws.data.attachId = null;
11289
11859
  log.error(`[ws] attach failed branch=${branch}: ${errMsg}`);
11290
11860
  sendWs(ws, { type: "error", message: errMsg });
11291
11861
  ws.close(1011, errMsg.slice(0, 123));
11292
11862
  }
11293
11863
  } else {
11294
- const worktreeId = getAttachedWorktreeId(ws);
11295
- if (!worktreeId)
11864
+ const attachId = getAttachedSessionId(ws);
11865
+ if (!attachId)
11296
11866
  return;
11297
- await resize(worktreeId, msg.cols, msg.rows);
11867
+ await resize(attachId, msg.cols, msg.rows);
11298
11868
  }
11299
11869
  break;
11300
11870
  }
11301
11871
  },
11302
11872
  async close(ws, code, reason) {
11303
- log.debug(`[ws] close branch=${ws.data.branch} code=${code} reason=${reason} attached=${ws.data.attached} worktreeId=${ws.data.worktreeId}`);
11304
- if (ws.data.worktreeId) {
11305
- clearCallbacks(ws.data.worktreeId);
11306
- await detach(ws.data.worktreeId);
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);
11307
11877
  }
11308
11878
  }
11309
11879
  }
@@ -11315,6 +11885,9 @@ if (tmuxCheck.exitCode !== 0) {
11315
11885
  }
11316
11886
  cleanupStaleSessions();
11317
11887
  startPrMonitor(getWorktreeGitDirs, config.integrations.github.linkedRepos, PROJECT_DIR, undefined, hasRecentDashboardActivity);
11888
+ if (linearAutoCreateEnabled) {
11889
+ startLinearAutoCreate();
11890
+ }
11318
11891
  log.info(`Dev Dashboard API running at http://localhost:${PORT}`);
11319
11892
  var nets = networkInterfaces();
11320
11893
  for (const addrs of Object.values(nets)) {