@straiffi/archon 1.1.2 → 1.2.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.
Files changed (39) hide show
  1. package/README.md +4 -0
  2. package/dist/client/assets/TestsDialog-Coa2wGbr.js +5 -0
  3. package/dist/client/assets/badge-Bpry9xkS.js +41 -0
  4. package/dist/client/assets/index-Bw66dtZG.css +2 -0
  5. package/dist/client/assets/index-WH13gBCE.js +151 -0
  6. package/dist/client/index.html +3 -3
  7. package/dist/server/index.js +201 -446
  8. package/dist/server/index.js.map +1 -1
  9. package/dist/server/lib/chatOperations.js +126 -0
  10. package/dist/server/lib/chatOperations.js.map +1 -0
  11. package/dist/server/lib/desktopServerHost.js +16 -0
  12. package/dist/server/lib/desktopServerHost.js.map +1 -0
  13. package/dist/server/lib/mobileAccess.js +1035 -0
  14. package/dist/server/lib/mobileAccess.js.map +1 -0
  15. package/dist/server/lib/mobileAccessSecurity.js +42 -0
  16. package/dist/server/lib/mobileAccessSecurity.js.map +1 -0
  17. package/dist/server/lib/ngrok.js +144 -0
  18. package/dist/server/lib/ngrok.js.map +1 -0
  19. package/dist/server/lib/projects.js +1 -0
  20. package/dist/server/lib/projects.js.map +1 -1
  21. package/dist/server/lib/realtime.js +2 -0
  22. package/dist/server/lib/realtime.js.map +1 -0
  23. package/dist/server/lib/run.js +3 -0
  24. package/dist/server/lib/run.js.map +1 -1
  25. package/dist/server/lib/staticClient.js +14 -3
  26. package/dist/server/lib/staticClient.js.map +1 -1
  27. package/dist/server/lib/ticketFollowUpOperations.js +87 -0
  28. package/dist/server/lib/ticketFollowUpOperations.js.map +1 -0
  29. package/dist/server/lib/ticketRunOperations.js +333 -0
  30. package/dist/server/lib/ticketRunOperations.js.map +1 -0
  31. package/dist/server/lib/ticketSettingsOperations.js +62 -0
  32. package/dist/server/lib/ticketSettingsOperations.js.map +1 -0
  33. package/dist/server/lib/ticketWorkflowOperations.js +114 -0
  34. package/dist/server/lib/ticketWorkflowOperations.js.map +1 -0
  35. package/package.json +1 -1
  36. package/dist/client/assets/TestsDialog-F0VCXRNc.js +0 -5
  37. package/dist/client/assets/badge-BentDQnz.js +0 -41
  38. package/dist/client/assets/index-CyyQsO8z.js +0 -142
  39. package/dist/client/assets/index-Dr_tNX7Y.css +0 -2
@@ -7,8 +7,9 @@ import { randomUUID } from 'crypto';
7
7
  import cors from 'cors';
8
8
  import { adjectives, animals, uniqueNamesGenerator } from 'unique-names-generator';
9
9
  import db from './db.js';
10
- import { createChatSession, deleteChatSession, getChatSession, listChatSessions, updateChatSessionMode } from './lib/chats.js';
11
- import { doesChatSessionTargetMatchProjectTarget, resolveChatSessionTarget } from './lib/chatTargets.js';
10
+ import { deleteChatSession, getChatSession, listChatSessions, updateChatSessionMode } from './lib/chats.js';
11
+ import { createProjectChatSession, resolveChatMode, resolveChatTool, stopProjectChatSessionResponse, submitProjectChatSessionMessage, } from './lib/chatOperations.js';
12
+ import { resolveChatSessionTarget } from './lib/chatTargets.js';
12
13
  import { createChatMessage, getChatMessage, updateChatMessageContent } from './lib/chatMessages.js';
13
14
  import { attachChatTicketProposalsToMessageContent } from './lib/chatTicketProposals.js';
14
15
  import { createCorsOriginResolver, parseCorsOrigins } from './lib/cors.js';
@@ -16,12 +17,15 @@ import { branchExistsLocally, commitGitChanges, createWorktreeAsync, deleteWorkt
16
17
  import config from './lib/config.js';
17
18
  import { autoConfigProject, ProjectAutoConfigError } from './lib/projectAutoConfig.js';
18
19
  import { browseRepoPath } from './lib/directoryPicker.js';
19
- import { isBuildAgentRunning, refreshChatSessionUsageSnapshot, stopAllBuildAgents, stopBuildAgent, stopChatAgent } from './lib/agent.js';
20
+ import { resolveDesktopServerHost } from './lib/desktopServerHost.js';
21
+ import { emitMobileRealtime, getMobileAccessStatus, regenerateMobilePairingChallenge, revokeMobileSession, startMobileAccess, stopMobileAccess, } from './lib/mobileAccess.js';
22
+ import { validateDesktopMobileAccessRequest } from './lib/mobileAccessSecurity.js';
23
+ import { refreshChatSessionUsageSnapshot, stopAllBuildAgents } from './lib/agent.js';
20
24
  import { runLightweightPrompt } from './lib/lightweightPrompt.js';
21
25
  import { startBuildChain, stopBuildChain } from './lib/buildChains.js';
22
- import { enqueueBundledBuild, getDependencyOrderedBundleTicketIds, hasActiveSequenceForBundle, removeTicketFromBuildSequence, stopAllActiveBuildSequences, } from './lib/buildSequences.js';
26
+ import { enqueueBundledBuild, getDependencyOrderedBundleTicketIds, removeTicketFromBuildSequence, stopAllActiveBuildSequences, } from './lib/buildSequences.js';
23
27
  import { prepareTicketForBuild } from './lib/buildFlow.js';
24
- import { getBundlePlanMutationBlockerById, isBundleBuildBlocked, shouldBlockPlanForBundleMutationById, } from './lib/bundleActivity.js';
28
+ import { getBundlePlanMutationBlockerById, shouldBlockPlanForBundleMutationById, } from './lib/bundleActivity.js';
25
29
  import { getDiscoveredModels, normalizeModelId } from './lib/models.js';
26
30
  import { acceptProjectMemorySuggestion, applyProjectMemorySuggestionToDecision, archiveProjectConvention, archiveProjectDecision, createProjectConvention, createProjectDecision, dismissProjectMemorySuggestion, getProjectMemoryById, getProjectMemorySuggestionById, isProjectMemoryEnabled, listProjectConventions, listLatestSuccessfulProjectContextArtifacts, listProjectDecisions, listProjectMemories, listProjectMemorySuggestions, restoreProjectConvention, restoreProjectDecision, serializeProjectMemoryFromConventionValue, serializeProjectMemoryFromDecisionValue, serializeProjectMemoryFromSuggestionValue, supersedeProjectDecision, updateProjectConvention, updateProjectDecision, validateProjectConventionPayload, validateProjectDecisionPayload, } from './lib/projectMemory.js';
27
31
  import { selectRelevantProjectMemory } from './lib/projectMemoryPrompt.js';
@@ -39,17 +43,19 @@ import { importJiraIssue, JiraApiError, validateJiraConnection } from './lib/int
39
43
  import { JiraPlanningReferenceError, resolveJiraPlanningDescription } from './lib/integrations/planning.js';
40
44
  import { getBundlePullRequestRecord, hasOpenBundlePullRequest, serializeBundlePullRequest, shouldSyncBundlePullRequest, upsertBundlePullRequest, } from './lib/bundlePullRequests.js';
41
45
  import { countInboundProjectLinks, getEffectiveProject, getProjectById, getProjectTicketStats, hasProjectRunIde, listProjects, replaceProjectLinks, serializeProject, updateProjectActiveTarget, validateProjectPayload, validateRepoPath, } from './lib/projects.js';
42
- import { getActiveRunStatus, getActiveRunStatusForProject, getPreviewStatusForProject, hasRunConfig, isRunning, openIde, openTicketIdeInWorkspace, resolveProjectRunContextKey, runProject, runSetupCommandsInWorkspace, runTicketInWorkspace, stopAllTickets, stopProjectRun, stopTicket } from './lib/run.js';
46
+ import { getActiveRunStatus, getActiveRunStatusForProject, getPreviewStatusForProject, hasRunConfig, isRunning, openIde, openTicketIdeInWorkspace, resolveProjectRunContextKey, runSetupCommandsInWorkspace, runTicketInWorkspace, stopAllTickets, stopTicket } from './lib/run.js';
43
47
  import { clearAllTestSessions, createTestSession, deleteTestSession, discoverTestsForSession, doesTestFileExistForSession, findProjectTestCommand, getTestSession, isTestSelectionSupported, startTestSessionRun, stopTestSessionRun } from './lib/testSessions.js';
48
+ import { getBundleBuildBlockerResponse, shouldStartBundleBuildNow, transitionTicketState, } from './lib/ticketWorkflowOperations.js';
49
+ import { submitPlanTicketFollowUp, submitTicketFollowUp, } from './lib/ticketFollowUpOperations.js';
50
+ import { canUseBuildControls, formatActiveRunConflict, getTicketRunReadinessError, resumeBuildTicketRun, runProjectTarget, runTicketRun, stopBuildTicketRun, stopProjectTargetRun, stopTicketRun, } from './lib/ticketRunOperations.js';
51
+ import { isTicketAgentSettingsPayload, updateTicketAgentSettings, } from './lib/ticketSettingsOperations.js';
44
52
  import { getActiveBundleStageChangeHead, undoTicketStage } from './lib/ticketUndo.js';
45
53
  import { closeAllTerminalSessions, createProjectTerminalSession, createTerminalSessionForWorkspace, destroyTerminalSessionById, registerTerminalSocketHandlers, } from './lib/terminal.js';
46
54
  import { countBundleTickets, createTicketRecord, createBundle, deleteBundle, ensureProjectRootBundle, autoParkTicketIfStale, clearAutoParkDismissalIfNeeded, assignTicketDoneOrder, assignTicketLaneOrder, getBundle, getBundleByName, getBundleByBranch, getBundleRepresentativeTicketContext, isProjectRootBundle, getTicket, isBundledTicket, listTicketsInRunContext, listBundles, listBoardTicketEnrichment, listTickets, resolveTicketBranch, resolveTicketTool, } from './lib/tickets.js';
47
55
  import { createTicketMessage, upsertTicketDescriptionMessage } from './lib/ticketMessages.js';
48
56
  import { createTicketDependency, deleteTicketDependenciesForTicket, deleteTicketDependency, getTicketDependency, isTicketRunSetupReady, listTicketDependencies, validateCreateTicketDependency, } from './lib/ticketDependencies.js';
49
- import { resumeBuild, startBuild } from './workers/build.js';
50
- import { startChatSessionTurn } from './workers/chat.js';
57
+ import { startBuild } from './workers/build.js';
51
58
  import { startFollowUp } from './workers/followUp.js';
52
- import { startPlanFollowUp } from './workers/planFollowUp.js';
53
59
  import { expandPlanningTicketOutline, generatePlanningTicketOutline, generatePlanText, IMPLEMENTATION_READY_DESCRIPTION_SECTION_HEADINGS, prepareLinkedPlanningResearch, startPlan, } from './workers/plan.js';
54
60
  import { startReview } from './workers/review.js';
55
61
  class HttpError extends Error {
@@ -151,34 +157,9 @@ const getParkTicketError = (ticket) => {
151
157
  }
152
158
  return 'Only build and review tickets can be parked';
153
159
  };
154
- const getBundleBuildBlockerError = (kind) => {
155
- if (kind === 'review') {
156
- return 'A review is currently active in this bundle. Finish or stop it before starting a new build.';
157
- }
158
- return 'A follow-up job is currently modifying this bundle. Finish it before starting a new build.';
159
- };
160
160
  const getBundlePlanBlockerError = () => {
161
161
  return 'AI planning is blocked because this bundle worktree is currently being modified.';
162
162
  };
163
- const isBuildBlockerKind = (kind) => {
164
- return kind === 'review' || kind === 'build_follow_up' || kind === 'review_follow_up';
165
- };
166
- const getBundleBuildBlockerResponse = (ticket) => {
167
- const blocker = isBundleBuildBlocked(ticket);
168
- if (!blocker || !isBuildBlockerKind(blocker.kind)) {
169
- return null;
170
- }
171
- return {
172
- error: getBundleBuildBlockerError(blocker.kind),
173
- blocker_ticket_id: blocker.ticketId,
174
- };
175
- };
176
- const shouldStartBundleBuildNow = (ticket) => {
177
- if (!ticket.project_id || !ticket.worktree_bundle_id) {
178
- return true;
179
- }
180
- return !hasActiveSequenceForBundle(ticket.project_id, ticket.worktree_bundle_id);
181
- };
182
163
  const deleteTicketRecord = async (ticket) => {
183
164
  if (ticket.agent_status === 'running') {
184
165
  return { ok: false, status: 409, error: 'Cannot delete a ticket while the agent is running' };
@@ -275,6 +256,12 @@ const httpServer = createServer(app);
275
256
  const io = new Server(httpServer, {
276
257
  cors: { origin: CORS_ORIGIN_RESOLVER },
277
258
  });
259
+ const emitDesktopRealtime = io.emit.bind(io);
260
+ io.emit = ((event, ...args) => {
261
+ const result = emitDesktopRealtime(event, ...args);
262
+ emitMobileRealtime(event, args[0]);
263
+ return result;
264
+ });
278
265
  app.use(cors({ origin: CORS_ORIGIN_RESOLVER }));
279
266
  const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
280
267
  const resolveRequestedSkills = (value) => normalizeSkillNames(value);
@@ -517,19 +504,6 @@ const asProjectPayload = (body) => {
517
504
  const isWorktreeReady = (branch, project) => {
518
505
  return existsSync(resolveWorktreePath(branch, project));
519
506
  };
520
- const getTicketRunReadinessError = (ticket, project) => {
521
- const branch = resolveTicketBranch(ticket);
522
- if (!branch || !isWorktreeReady(branch, project)) {
523
- return 'Worktree not ready yet';
524
- }
525
- if (isTicketRunSetupReady(ticket)) {
526
- return null;
527
- }
528
- if (ticket.run_setup_status === 'preparing') {
529
- return 'Worktree setup is still running. Try again in a moment.';
530
- }
531
- return 'Worktree setup has not finished yet.';
532
- };
533
507
  // Any ticket left in 'running' on startup had its job lost when the server stopped
534
508
  db.prepare("UPDATE tickets SET agent_status = 'error' WHERE agent_status = 'running'").run();
535
509
  stopAllActiveBuildSequences();
@@ -573,15 +547,6 @@ const getProjectChatSession = (projectId, chatSessionId) => {
573
547
  }
574
548
  return chatSession;
575
549
  };
576
- const resolveChatMode = (value) => {
577
- return value === 'plan' ? 'plan' : 'build';
578
- };
579
- const resolveChatTool = (value) => {
580
- if (value === 'claude') {
581
- return 'claude';
582
- }
583
- return 'opencode';
584
- };
585
550
  const resolveTicketDiffWorkspace = (ticket) => {
586
551
  const project = getEffectiveProject(ticket);
587
552
  if (!project) {
@@ -1877,9 +1842,6 @@ const buildChatTicketCreationResultMessage = ({ createdTickets, createdDependenc
1877
1842
  dependencyLine,
1878
1843
  ].filter(Boolean).join('\n\n');
1879
1844
  };
1880
- const canUseBuildControls = (ticket) => {
1881
- return ticket.state === 'build' || (ticket.state === 'review' && ticket.review_follow_up_active === true);
1882
- };
1883
1845
  const emitRunStateForContext = (ticket, isRunningValue) => {
1884
1846
  const relatedTickets = listTicketsInRunContext(ticket);
1885
1847
  if (relatedTickets.length === 0) {
@@ -1890,38 +1852,63 @@ const emitRunStateForContext = (ticket, isRunningValue) => {
1890
1852
  io.emit('ticket:updated', { ...relatedTicket, is_running: isRunningValue });
1891
1853
  }
1892
1854
  };
1893
- const formatActiveRunConflict = (projectId) => {
1894
- const activeRun = getActiveRunStatusForProject(projectId);
1895
- if (!activeRun) {
1896
- return null;
1897
- }
1898
- if (activeRun.kind === 'project') {
1899
- const activeProject = getProjectById(activeRun.project_id);
1900
- const activeTargetBundle = activeRun.target_kind === 'bundle' && activeRun.target_bundle_id
1901
- ? getBundle(activeRun.target_bundle_id, activeRun.project_id)
1902
- : null;
1903
- return {
1904
- active_project_id: activeRun.project_id,
1905
- error: activeProject
1906
- ? activeTargetBundle
1907
- ? `Bundle target run for "${activeProject.name}" (${activeTargetBundle.name}) is already running. Stop it before starting another run.`
1908
- : `Repo root run for "${activeProject.name}" is already running. Stop it before starting another run.`
1909
- : activeTargetBundle
1910
- ? 'A bundle target run is already running. Stop it before starting another run.'
1911
- : 'A repo root run is already running. Stop it before starting another run.',
1912
- };
1855
+ app.use(express.json({ limit: '5mb' }));
1856
+ const requireDesktopMobileAccessRequest = (req, res) => {
1857
+ const rejectionReason = validateDesktopMobileAccessRequest(req);
1858
+ if (!rejectionReason) {
1859
+ return true;
1913
1860
  }
1914
- const activeTicket = getTicket(activeRun.ticket_id);
1915
- return {
1916
- active_ticket_id: activeRun.ticket_id,
1917
- error: activeTicket
1918
- ? activeTicket.bundle
1919
- ? `Ticket "${activeTicket.title}" in bundle "${activeTicket.bundle.name}" is already running. Stop it before starting another run.`
1920
- : `Ticket "${activeTicket.title}" is already running. Stop it before starting another run.`
1921
- : 'Another ticket is already running. Stop it before starting another run.',
1922
- };
1861
+ res.status(403).json({ error: rejectionReason });
1862
+ return false;
1923
1863
  };
1924
- app.use(express.json({ limit: '5mb' }));
1864
+ app.get('/mobile-access/status', (_req, res) => {
1865
+ res.setHeader('Cache-Control', 'no-store');
1866
+ res.json(getMobileAccessStatus());
1867
+ });
1868
+ app.post('/mobile-access/enable', async (req, res) => {
1869
+ if (!requireDesktopMobileAccessRequest(req, res)) {
1870
+ return;
1871
+ }
1872
+ const requestedMode = req.body?.mode === 'custom_public_url'
1873
+ ? 'custom_public_url'
1874
+ : req.body?.mode === 'ngrok'
1875
+ ? 'ngrok'
1876
+ : req.body?.mode === 'lan'
1877
+ ? 'lan'
1878
+ : null;
1879
+ const publicBaseUrl = typeof req.body?.public_base_url === 'string'
1880
+ ? req.body.public_base_url
1881
+ : null;
1882
+ res.setHeader('Cache-Control', 'no-store');
1883
+ res.json(await startMobileAccess({
1884
+ mode: requestedMode,
1885
+ clientBuild: resolveClientBuildPaths(import.meta.url),
1886
+ broadcaster: io,
1887
+ reviewBundleTickets,
1888
+ publicBaseUrl,
1889
+ }));
1890
+ });
1891
+ app.post('/mobile-access/disable', async (req, res) => {
1892
+ if (!requireDesktopMobileAccessRequest(req, res)) {
1893
+ return;
1894
+ }
1895
+ res.setHeader('Cache-Control', 'no-store');
1896
+ res.json(await stopMobileAccess());
1897
+ });
1898
+ app.post('/mobile-access/regenerate-pairing', (req, res) => {
1899
+ if (!requireDesktopMobileAccessRequest(req, res)) {
1900
+ return;
1901
+ }
1902
+ res.setHeader('Cache-Control', 'no-store');
1903
+ res.json(regenerateMobilePairingChallenge());
1904
+ });
1905
+ app.post('/mobile-access/sessions/:sessionId/revoke', (req, res) => {
1906
+ if (!requireDesktopMobileAccessRequest(req, res)) {
1907
+ return;
1908
+ }
1909
+ res.setHeader('Cache-Control', 'no-store');
1910
+ res.json(revokeMobileSession(req.params.sessionId));
1911
+ });
1925
1912
  app.get('/run-config', (req, res) => {
1926
1913
  const ticketId = typeof req.query.ticketId === 'string' ? req.query.ticketId : null;
1927
1914
  if (!ticketId) {
@@ -1964,29 +1951,15 @@ app.get('/projects/:id/chat-sessions', (req, res) => {
1964
1951
  return res.json(listChatSessions(project.id));
1965
1952
  });
1966
1953
  app.post('/projects/:id/chat-sessions', (req, res) => {
1967
- const project = getProjectById(req.params.id);
1968
- if (!project) {
1969
- return res.status(404).json({ error: 'Not found' });
1970
- }
1971
- const tool = resolveChatTool(req.body.tool);
1972
- const mode = resolveChatMode(req.body.mode);
1973
- const rawModel = typeof req.body.model === 'string' ? req.body.model : project.helper_model;
1974
- const normalizedModel = tool === 'opencode' ? normalizeModelId(rawModel) || null : null;
1975
- const normalizedVariant = tool === 'opencode' && normalizedModel && typeof req.body.variant === 'string'
1976
- ? req.body.variant.trim() || null
1977
- : tool === 'opencode' && normalizedModel
1978
- ? project.helper_variant ?? null
1979
- : null;
1980
- const chatSession = createChatSession({
1981
- projectId: project.id,
1982
- mode,
1983
- tool,
1984
- model: normalizedModel,
1985
- variant: normalizedVariant,
1986
- skills: normalizeSkillNames(req.body.skills),
1954
+ const result = createProjectChatSession({
1955
+ projectId: req.params.id,
1956
+ payload: req.body,
1957
+ broadcaster: io,
1987
1958
  });
1988
- io.emit('chat-session:created', chatSession);
1989
- return res.status(201).json(chatSession);
1959
+ if (!result.ok) {
1960
+ return res.status(result.status).json({ error: result.error });
1961
+ }
1962
+ return res.status(result.status).json(result.value);
1990
1963
  });
1991
1964
  app.get('/projects/:id/chat-sessions/:sessionId', (req, res) => {
1992
1965
  const project = getProjectById(req.params.id);
@@ -2020,55 +1993,26 @@ app.patch('/projects/:id/chat-sessions/:sessionId', (req, res) => {
2020
1993
  return res.json(updated);
2021
1994
  });
2022
1995
  app.post('/projects/:id/chat-sessions/:sessionId/messages', (req, res) => {
2023
- const project = getProjectById(req.params.id);
2024
- if (!project) {
2025
- return res.status(404).json({ error: 'Not found' });
2026
- }
2027
- const existing = getProjectChatSession(project.id, req.params.sessionId);
2028
- if (!existing) {
2029
- return res.status(404).json({ error: 'Not found' });
2030
- }
2031
- const instruction = typeof req.body.instruction === 'string' ? req.body.instruction.trim() : '';
2032
- if (!instruction) {
2033
- return res.status(400).json({ error: 'instruction is required' });
2034
- }
2035
- const targetResolution = resolveChatSessionTarget(existing, project);
2036
- if (!targetResolution.target_available) {
2037
- return res.status(409).json({ error: targetResolution.target_unavailable_reason ?? 'Chat target is unavailable.' });
2038
- }
2039
- if (!doesChatSessionTargetMatchProjectTarget(existing, project)) {
2040
- return res.status(409).json({ error: 'This chat session is out of sync with the current project target. Restore the session target to continue.' });
2041
- }
2042
- const rawModel = typeof req.body.model === 'string' ? req.body.model : project.helper_model;
2043
- const accepted = startChatSessionTurn({
2044
- chatSessionId: existing.id,
2045
- instruction,
2046
- tool: existing.tool,
2047
- model: typeof rawModel === 'string' ? rawModel : null,
2048
- variant: typeof req.body.variant === 'string' ? req.body.variant : project.helper_variant,
2049
- skills: normalizeSkillNames(req.body.skills),
2050
- io,
1996
+ const result = submitProjectChatSessionMessage({
1997
+ projectId: req.params.id,
1998
+ chatSessionId: req.params.sessionId,
1999
+ payload: req.body,
2000
+ broadcaster: io,
2051
2001
  });
2052
- if (!accepted) {
2053
- return res.status(409).json({ error: 'This chat session is already running.' });
2002
+ if (!result.ok) {
2003
+ return res.status(result.status).json({ error: result.error });
2054
2004
  }
2055
- const updated = getProjectChatSession(project.id, existing.id);
2056
- return res.json(updated);
2005
+ return res.status(result.status).json(result.value);
2057
2006
  });
2058
2007
  app.post('/projects/:id/chat-sessions/:sessionId/stop', async (req, res) => {
2059
- const project = getProjectById(req.params.id);
2060
- if (!project) {
2061
- return res.status(404).json({ error: 'Not found' });
2062
- }
2063
- const chatSession = getProjectChatSession(project.id, req.params.sessionId);
2064
- if (!chatSession) {
2065
- return res.status(404).json({ error: 'Not found' });
2066
- }
2067
- const stopped = await stopChatAgent(chatSession.id);
2068
- if (!stopped) {
2069
- return res.status(409).json({ error: 'No active chat response found for this session.' });
2008
+ const result = await stopProjectChatSessionResponse({
2009
+ projectId: req.params.id,
2010
+ chatSessionId: req.params.sessionId,
2011
+ });
2012
+ if (!result.ok) {
2013
+ return res.status(result.status).json({ error: result.error });
2070
2014
  }
2071
- return res.json({ ok: true });
2015
+ return res.status(result.status).json(result.value);
2072
2016
  });
2073
2017
  app.get('/projects/:id/chat-sessions/:sessionId/diff', (req, res) => {
2074
2018
  const project = getProjectById(req.params.id);
@@ -2292,7 +2236,8 @@ app.get('/tickets/:id/git-status', (req, res) => {
2292
2236
  if ('error' in workspace) {
2293
2237
  return res.status(workspace.status).json({ error: workspace.error });
2294
2238
  }
2295
- return res.json(getGitStatus(workspace.cwd, workspace.branch, workspace.project));
2239
+ const refreshRemote = req.query.refresh_remote === 'true';
2240
+ return res.json(getGitStatus(workspace.cwd, workspace.branch, workspace.project, { refreshRemote }));
2296
2241
  });
2297
2242
  app.get('/tickets/:id/git-commits', (req, res) => {
2298
2243
  const ticket = getTicketRouteContext(req.params.id);
@@ -4363,6 +4308,18 @@ app.patch('/tickets/:id', (req, res) => {
4363
4308
  const hasVariantField = hasOwn(req.body, 'variant');
4364
4309
  const hasSkillsField = hasOwn(req.body, 'skills');
4365
4310
  const isSettingsOnlyUpdate = !hasTitleField && !hasDescriptionField && !hasProjectField && !hasBundleField && (hasModelField || hasVariantField || hasSkillsField);
4311
+ if (isTicketAgentSettingsPayload(req.body)) {
4312
+ const result = updateTicketAgentSettings({
4313
+ ticketId: req.params.id,
4314
+ requestedProjectId: getRequestedProjectId(req),
4315
+ payload: req.body,
4316
+ broadcaster: io,
4317
+ });
4318
+ if (!result.ok) {
4319
+ return res.status(result.status).json({ error: result.error });
4320
+ }
4321
+ return res.status(result.status).json(result.value);
4322
+ }
4366
4323
  if (!isPlanTicket && !isSettingsOnlyUpdate) {
4367
4324
  return res.status(400).json({ error: 'Only plan tickets can be fully edited after work begins' });
4368
4325
  }
@@ -4480,79 +4437,21 @@ app.patch('/tickets/:id', (req, res) => {
4480
4437
  res.json(updated);
4481
4438
  });
4482
4439
  app.patch('/tickets/:id/state', (req, res) => {
4483
- const state = typeof req.body.state === 'string' ? req.body.state : '';
4484
- const validStates = ['plan', 'build', 'review'];
4485
- if (!validStates.includes(state)) {
4486
- return res.status(400).json({ error: 'Invalid state' });
4487
- }
4488
- const existing = getTicket(req.params.id);
4489
- if (!existing) {
4490
- return res.status(404).json({ error: 'Not found' });
4491
- }
4492
- if (!matchesTicketProjectContext(existing, req)) {
4493
- return res.status(404).json({ error: 'Not found' });
4494
- }
4495
- if (state === 'plan' && existing.state !== 'plan') {
4496
- return res.status(409).json({ error: 'Tickets cannot be moved back to plan after work begins' });
4497
- }
4498
- if (state === 'build') {
4499
- const buildBlocker = getBundleBuildBlockerResponse(existing);
4500
- if (buildBlocker) {
4501
- return res.status(409).json(buildBlocker);
4502
- }
4503
- const buildTransition = prepareTicketForBuild(existing.id);
4504
- if (!buildTransition.ok) {
4505
- return res.status(buildTransition.status).json({
4506
- error: buildTransition.error,
4507
- ...(buildTransition.blockers ? { blockers: buildTransition.blockers } : {}),
4508
- });
4509
- }
4510
- io.emit('ticket:updated', buildTransition.ticket);
4511
- res.json(buildTransition.ticket);
4512
- if (buildTransition.ticket.project_id && buildTransition.ticket.worktree_bundle_id) {
4513
- const queuedBuild = enqueueBundledBuild({
4514
- ticketId: buildTransition.ticket.id,
4515
- projectId: buildTransition.ticket.project_id,
4516
- worktreeBundleId: buildTransition.ticket.worktree_bundle_id,
4517
- activate: shouldStartBundleBuildNow(buildTransition.ticket),
4518
- });
4519
- if (!queuedBuild.shouldStartNow) {
4520
- return;
4521
- }
4522
- }
4523
- startBuild(buildTransition.ticket, io);
4524
- return;
4525
- }
4526
- if (state === 'review' && (existing.state !== 'build' || existing.agent_status !== 'done')) {
4527
- return res.status(409).json({ error: 'Tickets can only be moved to review from build when done' });
4528
- }
4529
- const project = getEffectiveProject(existing);
4530
- if (state === 'review' && !project) {
4531
- return res.status(400).json({ error: 'Ticket project no longer exists' });
4532
- }
4533
- if (state === 'review' && existing.project_id && existing.worktree_bundle_id) {
4534
- const reviewResult = reviewBundleTickets(existing.project_id, existing.worktree_bundle_id);
4535
- if (!reviewResult.ok) {
4536
- return res.status(reviewResult.status).json({ error: reviewResult.error });
4537
- }
4538
- const updated = reviewResult.details.tickets.find(ticket => ticket.id === existing.id);
4539
- if (!updated) {
4540
- return res.status(409).json({ error: 'Unable to update the requested ticket in this bundle' });
4541
- }
4542
- return res.json(updated);
4440
+ const result = transitionTicketState({
4441
+ ticketId: req.params.id,
4442
+ requestedState: req.body.state,
4443
+ requestedProjectId: getRequestedProjectId(req),
4444
+ broadcaster: io,
4445
+ reviewBundleTickets,
4446
+ });
4447
+ if (!result.ok) {
4448
+ return res.status(result.status).json({
4449
+ error: result.error,
4450
+ ...result.details,
4451
+ });
4543
4452
  }
4544
- const nextAgentStatus = state === 'review' ? null : existing.agent_status;
4545
- const nextAgentLog = state === 'review' ? null : existing.agent_log;
4546
- const nextSessionId = existing.session_id;
4547
- db.prepare('UPDATE tickets SET state = ?, tool = ?, agent_status = ?, agent_log = ?, session_id = ?, streaming_response = NULL, lane_order = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(state, resolveTicketTool(existing, config.tool ?? 'opencode'), nextAgentStatus, nextAgentLog, nextSessionId, assignTicketLaneOrder({ ...existing, state }) ?? null, req.params.id);
4548
- const ticket = autoParkTicketIfStale(req.params.id);
4549
- if (state === 'review' && ticket) {
4550
- startReview(ticket, io);
4551
- const runningReview = getTicket(req.params.id);
4552
- return res.json(runningReview);
4553
- }
4554
- io.emit('ticket:updated', ticket);
4555
- res.json(ticket);
4453
+ res.status(result.status).json(result.value);
4454
+ result.afterResponse?.();
4556
4455
  });
4557
4456
  app.patch('/tickets/:id/park', (req, res) => {
4558
4457
  const ticket = getTicket(req.params.id);
@@ -4754,41 +4653,19 @@ app.post('/tickets/:id/undo', (req, res) => {
4754
4653
  return res.json(result.ticket);
4755
4654
  });
4756
4655
  app.post('/tickets/:id/run', (req, res) => {
4757
- const ticket = getTicket(req.params.id);
4758
- if (!ticket) {
4759
- return res.status(404).json({ error: 'Not found' });
4760
- }
4761
- if (!matchesTicketProjectContext(ticket, req)) {
4762
- return res.status(404).json({ error: 'Not found' });
4763
- }
4764
- const project = getEffectiveProject(ticket);
4765
- if (!project) {
4766
- return res.status(400).json({ error: 'Ticket project no longer exists' });
4767
- }
4768
- const workspace = resolveTicketGitWorkspace(ticket);
4769
- if ('error' in workspace) {
4770
- return res.status(workspace.status).json({ error: workspace.error });
4771
- }
4772
- if (!hasRunConfig(project)) {
4773
- return res.status(400).json({ error: 'No run config' });
4774
- }
4775
- const runReadinessError = getTicketRunReadinessError(ticket, project);
4776
- if (runReadinessError) {
4777
- return res.status(409).json({ error: runReadinessError });
4778
- }
4779
- if (isRunning(ticket)) {
4780
- emitRunStateForContext(ticket, true);
4781
- const runningTicket = listTicketsInRunContext(ticket).find(entry => entry.id === ticket.id);
4782
- return res.json(runningTicket ?? { ...ticket, is_running: true, has_run_config: true });
4783
- }
4784
- const activeRunConflict = formatActiveRunConflict(ticket.project_id);
4785
- if (activeRunConflict) {
4786
- return res.status(409).json(activeRunConflict);
4656
+ const result = runTicketRun({
4657
+ ticketId: req.params.id,
4658
+ requestedProjectId: getRequestedProjectId(req),
4659
+ broadcaster: io,
4660
+ });
4661
+ if (!result.ok) {
4662
+ return res.status(result.status).json({
4663
+ error: result.error,
4664
+ ...result.details,
4665
+ });
4787
4666
  }
4788
- const running = { ...ticket, is_running: true, has_run_config: true };
4789
- emitRunStateForContext(ticket, true);
4790
- res.json(running);
4791
- runTicketInWorkspace(ticket, io, project, workspace.cwd);
4667
+ res.status(result.status).json(result.value);
4668
+ result.afterResponse?.();
4792
4669
  });
4793
4670
  app.post('/tickets/:id/open-ide', (req, res) => {
4794
4671
  const ticket = getTicket(req.params.id);
@@ -4981,46 +4858,17 @@ app.post('/projects/:id/open-ide', (req, res) => {
4981
4858
  return res.json({ ok: true });
4982
4859
  });
4983
4860
  app.post('/projects/:id/run', (req, res) => {
4984
- const project = getProjectById(req.params.id);
4985
- if (!project) {
4986
- return res.status(404).json({ error: 'Not found' });
4987
- }
4988
- if (!hasRunConfig(project)) {
4989
- return res.status(400).json({ error: 'No run config' });
4990
- }
4991
- const workspace = resolveProjectTargetGitWorkspace(project);
4992
- if ('error' in workspace) {
4993
- return res.status(workspace.status).json({ error: workspace.error });
4994
- }
4995
- const contextKey = workspace.run_context_key;
4996
- if (isRunning(contextKey)) {
4997
- return res.json({
4998
- active_run: {
4999
- kind: 'project',
5000
- context_key: contextKey,
5001
- project_id: project.id,
5002
- target_kind: workspace.target_kind,
5003
- target_bundle_id: workspace.target_bundle_id,
5004
- },
4861
+ const result = runProjectTarget({
4862
+ projectId: req.params.id,
4863
+ });
4864
+ if (!result.ok) {
4865
+ return res.status(result.status).json({
4866
+ error: result.error,
4867
+ ...result.details,
5005
4868
  });
5006
4869
  }
5007
- const activeRunConflict = formatActiveRunConflict(project.id);
5008
- if (activeRunConflict) {
5009
- return res.status(409).json(activeRunConflict);
5010
- }
5011
- const activeRun = {
5012
- kind: 'project',
5013
- context_key: contextKey,
5014
- project_id: project.id,
5015
- target_kind: workspace.target_kind,
5016
- target_bundle_id: workspace.target_bundle_id,
5017
- };
5018
- res.json({ active_run: activeRun });
5019
- void runProject(project, {
5020
- cwd: workspace.cwd,
5021
- targetKind: workspace.target_kind,
5022
- targetBundleId: workspace.target_bundle_id,
5023
- });
4870
+ res.status(result.status).json(result.value);
4871
+ result.afterResponse?.();
5024
4872
  });
5025
4873
  app.post('/projects/:id/test-sessions', (req, res) => {
5026
4874
  const project = getProjectById(req.params.id);
@@ -5203,154 +5051,69 @@ app.post('/terminals/:sessionId/destroy', (req, res) => {
5203
5051
  return res.status(204).send();
5204
5052
  });
5205
5053
  app.post('/tickets/:id/stop', async (req, res) => {
5206
- const ticket = getTicket(req.params.id);
5207
- if (!ticket) {
5208
- return res.status(404).json({ error: 'Not found' });
5209
- }
5210
- if (!matchesTicketProjectContext(ticket, req)) {
5211
- return res.status(404).json({ error: 'Not found' });
5054
+ const result = await stopTicketRun({
5055
+ ticketId: req.params.id,
5056
+ requestedProjectId: getRequestedProjectId(req),
5057
+ broadcaster: io,
5058
+ });
5059
+ if (!result.ok) {
5060
+ return res.status(result.status).json({ error: result.error });
5212
5061
  }
5213
- await stopTicket(ticket);
5214
- emitRunStateForContext(ticket, false);
5215
- const updated = listTicketsInRunContext(ticket).find(entry => entry.id === ticket.id) ?? { ...ticket, is_running: false };
5216
- res.json(updated);
5062
+ return res.status(result.status).json(result.value);
5217
5063
  });
5218
5064
  app.post('/projects/:id/stop-run', async (req, res) => {
5219
- const project = getProjectById(req.params.id);
5220
- if (!project) {
5221
- return res.status(404).json({ error: 'Not found' });
5222
- }
5223
- await stopProjectRun(project.id, {
5224
- kind: project.active_target_kind,
5225
- bundleId: project.active_target_bundle_id,
5065
+ const result = await stopProjectTargetRun({
5066
+ projectId: req.params.id,
5226
5067
  });
5227
- return res.json({ active_run: getActiveRunStatusForProject(project.id) });
5068
+ if (!result.ok) {
5069
+ return res.status(result.status).json({ error: result.error });
5070
+ }
5071
+ return res.status(result.status).json(result.value);
5228
5072
  });
5229
5073
  app.post('/tickets/:id/stop-build', async (req, res) => {
5230
- const ticket = getTicket(req.params.id);
5231
- if (!ticket) {
5232
- return res.status(404).json({ error: 'Not found' });
5233
- }
5234
- if (!matchesTicketProjectContext(ticket, req)) {
5235
- return res.status(404).json({ error: 'Not found' });
5236
- }
5237
- if (!canUseBuildControls(ticket)) {
5238
- return res.status(400).json({ error: 'Only build-stage and review follow-up tickets can stop builds' });
5239
- }
5240
- if (ticket.agent_status !== 'running') {
5241
- return res.status(400).json({ error: 'Ticket build is not running' });
5242
- }
5243
- if (!ticket.session_id) {
5244
- return res.status(400).json({ error: 'Build session is not ready to stop yet' });
5245
- }
5246
- if (!isBuildAgentRunning(ticket.id)) {
5247
- return res.status(409).json({ error: 'No active build process found for this ticket' });
5248
- }
5249
- await stopBuildAgent(ticket.id);
5250
- const updated = getTicket(req.params.id);
5251
- if (!updated) {
5252
- return res.status(404).json({ error: 'Not found' });
5074
+ const result = await stopBuildTicketRun({
5075
+ ticketId: req.params.id,
5076
+ requestedProjectId: getRequestedProjectId(req),
5077
+ });
5078
+ if (!result.ok) {
5079
+ return res.status(result.status).json({ error: result.error });
5253
5080
  }
5254
- res.json(updated);
5081
+ return res.status(result.status).json(result.value);
5255
5082
  });
5256
5083
  app.post('/tickets/:id/resume-build', (req, res) => {
5257
- const existing = getTicket(req.params.id);
5258
- if (!existing) {
5259
- return res.status(404).json({ error: 'Not found' });
5260
- }
5261
- if (!matchesTicketProjectContext(existing, req)) {
5262
- return res.status(404).json({ error: 'Not found' });
5263
- }
5264
- if (!canUseBuildControls(existing)) {
5265
- return res.status(400).json({ error: 'Only build-stage and review follow-up tickets can resume builds' });
5266
- }
5267
- if (existing.agent_status !== 'stopped') {
5268
- return res.status(400).json({ error: 'Only manually stopped builds can be resumed' });
5269
- }
5270
- if (!existing.session_id) {
5271
- return res.status(400).json({ error: 'Build session is not available to resume' });
5272
- }
5273
- db.prepare('UPDATE tickets SET build_completed_at = NULL, auto_park_dismissed_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(existing.id);
5274
- const ticket = getTicket(existing.id);
5275
- if (!ticket) {
5276
- return res.status(404).json({ error: 'Not found' });
5084
+ const result = resumeBuildTicketRun({
5085
+ ticketId: req.params.id,
5086
+ requestedProjectId: getRequestedProjectId(req),
5087
+ broadcaster: io,
5088
+ });
5089
+ if (!result.ok) {
5090
+ return res.status(result.status).json({ error: result.error });
5277
5091
  }
5278
- resumeBuild(ticket, io);
5279
- res.json(ticket);
5092
+ return res.status(result.status).json(result.value);
5280
5093
  });
5281
5094
  app.post('/tickets/:id/follow-up', (req, res) => {
5282
- const ticket = getTicket(req.params.id);
5283
- if (!ticket) {
5284
- return res.status(404).json({ error: 'Not found' });
5285
- }
5286
- if (!matchesTicketProjectContext(ticket, req)) {
5287
- return res.status(404).json({ error: 'Not found' });
5288
- }
5289
- if (ticket.state === 'plan') {
5290
- return res.status(400).json({ error: 'Only non-plan tickets can accept follow-up prompts' });
5291
- }
5292
- if (ticket.agent_status === 'running') {
5293
- return res.status(409).json({ error: 'Ticket is already running' });
5294
- }
5295
- const instruction = typeof req.body?.instruction === 'string'
5296
- ? req.body.instruction.trim()
5297
- : '';
5298
- if (!instruction) {
5299
- return res.status(400).json({ error: 'instruction is required' });
5300
- }
5301
- const project = getEffectiveProject(ticket);
5302
- if (!project) {
5303
- return res.status(400).json({ error: 'Ticket project no longer exists' });
5304
- }
5305
- const branch = resolveTicketBranch(ticket);
5306
- if (!branch || ticket.has_worktree === false || !isWorktreeReady(branch, project)) {
5307
- return res.status(400).json({ error: 'Worktree not ready yet' });
5308
- }
5309
- createTicketMessage({
5310
- ticketId: ticket.id,
5311
- role: 'user',
5312
- kind: 'follow_up',
5313
- content: instruction,
5095
+ const result = submitTicketFollowUp({
5096
+ ticketId: req.params.id,
5097
+ requestedProjectId: getRequestedProjectId(req),
5098
+ payload: req.body,
5099
+ broadcaster: io,
5314
5100
  });
5315
- const started = startFollowUp(ticket, io, instruction);
5316
- if (!started) {
5317
- return res.status(409).json({ error: 'Ticket is already running' });
5101
+ if (!result.ok) {
5102
+ return res.status(result.status).json({ error: result.error });
5318
5103
  }
5319
- const updated = getTicket(ticket.id);
5320
- return res.json(updated ?? ticket);
5104
+ return res.status(result.status).json(result.value);
5321
5105
  });
5322
5106
  app.post('/tickets/:id/plan-follow-up', (req, res) => {
5323
- const ticket = getTicket(req.params.id);
5324
- if (!ticket) {
5325
- return res.status(404).json({ error: 'Not found' });
5326
- }
5327
- if (!matchesTicketProjectContext(ticket, req)) {
5328
- return res.status(404).json({ error: 'Not found' });
5329
- }
5330
- if (ticket.state !== 'plan') {
5331
- return res.status(400).json({ error: 'Only plan tickets can accept planning follow-up prompts' });
5332
- }
5333
- if (ticket.agent_status === 'running') {
5334
- return res.status(409).json({ error: 'Ticket is already running' });
5335
- }
5336
- const instruction = typeof req.body?.instruction === 'string'
5337
- ? req.body.instruction.trim()
5338
- : '';
5339
- if (!instruction) {
5340
- return res.status(400).json({ error: 'instruction is required' });
5341
- }
5342
- createTicketMessage({
5343
- ticketId: ticket.id,
5344
- role: 'user',
5345
- kind: 'planning_follow_up',
5346
- content: instruction,
5107
+ const result = submitPlanTicketFollowUp({
5108
+ ticketId: req.params.id,
5109
+ requestedProjectId: getRequestedProjectId(req),
5110
+ payload: req.body,
5111
+ broadcaster: io,
5347
5112
  });
5348
- const started = startPlanFollowUp(ticket, io, instruction);
5349
- if (!started) {
5350
- return res.status(409).json({ error: 'Ticket is already running' });
5113
+ if (!result.ok) {
5114
+ return res.status(result.status).json({ error: result.error });
5351
5115
  }
5352
- const updated = getTicket(ticket.id);
5353
- return res.json(updated ?? ticket);
5116
+ return res.status(result.status).json(result.value);
5354
5117
  });
5355
5118
  app.post('/tickets/:id/plan', (req, res) => {
5356
5119
  const ticket = getTicket(req.params.id);
@@ -5429,26 +5192,20 @@ io.on('connection', (socket) => {
5429
5192
  registerTerminalSocketHandlers(io, socket);
5430
5193
  });
5431
5194
  const DEFAULT_PORT = 3001;
5432
- const resolveListeningHost = (host) => {
5433
- if (!host || host === '0.0.0.0' || host === '::') {
5434
- return 'localhost';
5435
- }
5436
- return host;
5437
- };
5438
5195
  const resolveStartResult = (host) => {
5439
5196
  const address = httpServer.address();
5440
- const listeningHost = resolveListeningHost(host);
5197
+ const { displayHost } = resolveDesktopServerHost(host);
5441
5198
  if (!address || typeof address === 'string') {
5442
5199
  return {
5443
- host: listeningHost,
5200
+ host: displayHost,
5444
5201
  port: DEFAULT_PORT,
5445
- url: `http://${listeningHost}:${DEFAULT_PORT}`,
5202
+ url: `http://${displayHost}:${DEFAULT_PORT}`,
5446
5203
  };
5447
5204
  }
5448
5205
  return {
5449
- host: listeningHost,
5206
+ host: displayHost,
5450
5207
  port: address.port,
5451
- url: `http://${listeningHost}:${address.port}`,
5208
+ url: `http://${displayHost}:${address.port}`,
5452
5209
  };
5453
5210
  };
5454
5211
  export const startServer = ({ port = DEFAULT_PORT, host } = {}) => {
@@ -5466,14 +5223,12 @@ export const startServer = ({ port = DEFAULT_PORT, host } = {}) => {
5466
5223
  resolve(resolveStartResult(host));
5467
5224
  };
5468
5225
  httpServer.once('error', handleError);
5469
- if (host) {
5470
- httpServer.listen(port, host, handleListening);
5471
- return;
5472
- }
5473
- httpServer.listen(port, handleListening);
5226
+ const { bindHost } = resolveDesktopServerHost(host);
5227
+ httpServer.listen(port, bindHost, handleListening);
5474
5228
  });
5475
5229
  };
5476
5230
  export const shutdownServer = async (signal) => {
5231
+ await stopMobileAccess();
5477
5232
  await clearAllTestSessions(io);
5478
5233
  await shutdownRealtimeServer({
5479
5234
  signal,