@straiffi/archon 1.1.3 → 1.2.1

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-4_jJ_cnr.js +5 -0
  3. package/dist/client/assets/badge-Bpry9xkS.js +41 -0
  4. package/dist/client/assets/index-BgmAtYCf.js +151 -0
  5. package/dist/client/assets/index-Bw66dtZG.css +2 -0
  6. package/dist/client/index.html +3 -3
  7. package/dist/server/index.js +206 -445
  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 +1051 -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-Buw5J9nT.js +0 -5
  37. package/dist/client/assets/badge-BentDQnz.js +0 -41
  38. package/dist/client/assets/index-Dr_tNX7Y.css +0 -2
  39. package/dist/client/assets/index-kbsOL4Bp.js +0 -142
@@ -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,70 @@ 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 preferredProjectId = typeof req.body?.preferred_project_id === 'string'
1873
+ ? req.body.preferred_project_id
1874
+ : null;
1875
+ const requestedMode = req.body?.mode === 'custom_public_url'
1876
+ ? 'custom_public_url'
1877
+ : req.body?.mode === 'ngrok'
1878
+ ? 'ngrok'
1879
+ : req.body?.mode === 'lan'
1880
+ ? 'lan'
1881
+ : null;
1882
+ const publicBaseUrl = typeof req.body?.public_base_url === 'string'
1883
+ ? req.body.public_base_url
1884
+ : null;
1885
+ res.setHeader('Cache-Control', 'no-store');
1886
+ res.json(await startMobileAccess({
1887
+ mode: requestedMode,
1888
+ clientBuild: resolveClientBuildPaths(import.meta.url),
1889
+ broadcaster: io,
1890
+ preferredProjectId,
1891
+ reviewBundleTickets,
1892
+ publicBaseUrl,
1893
+ }));
1894
+ });
1895
+ app.post('/mobile-access/disable', async (req, res) => {
1896
+ if (!requireDesktopMobileAccessRequest(req, res)) {
1897
+ return;
1898
+ }
1899
+ res.setHeader('Cache-Control', 'no-store');
1900
+ res.json(await stopMobileAccess());
1901
+ });
1902
+ app.post('/mobile-access/regenerate-pairing', (req, res) => {
1903
+ if (!requireDesktopMobileAccessRequest(req, res)) {
1904
+ return;
1905
+ }
1906
+ res.setHeader('Cache-Control', 'no-store');
1907
+ const preferredProjectId = typeof req.body?.preferred_project_id === 'string'
1908
+ ? req.body.preferred_project_id
1909
+ : null;
1910
+ res.json(regenerateMobilePairingChallenge(preferredProjectId));
1911
+ });
1912
+ app.post('/mobile-access/sessions/:sessionId/revoke', (req, res) => {
1913
+ if (!requireDesktopMobileAccessRequest(req, res)) {
1914
+ return;
1915
+ }
1916
+ res.setHeader('Cache-Control', 'no-store');
1917
+ res.json(revokeMobileSession(req.params.sessionId));
1918
+ });
1925
1919
  app.get('/run-config', (req, res) => {
1926
1920
  const ticketId = typeof req.query.ticketId === 'string' ? req.query.ticketId : null;
1927
1921
  if (!ticketId) {
@@ -1964,29 +1958,15 @@ app.get('/projects/:id/chat-sessions', (req, res) => {
1964
1958
  return res.json(listChatSessions(project.id));
1965
1959
  });
1966
1960
  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),
1961
+ const result = createProjectChatSession({
1962
+ projectId: req.params.id,
1963
+ payload: req.body,
1964
+ broadcaster: io,
1987
1965
  });
1988
- io.emit('chat-session:created', chatSession);
1989
- return res.status(201).json(chatSession);
1966
+ if (!result.ok) {
1967
+ return res.status(result.status).json({ error: result.error });
1968
+ }
1969
+ return res.status(result.status).json(result.value);
1990
1970
  });
1991
1971
  app.get('/projects/:id/chat-sessions/:sessionId', (req, res) => {
1992
1972
  const project = getProjectById(req.params.id);
@@ -2020,55 +2000,26 @@ app.patch('/projects/:id/chat-sessions/:sessionId', (req, res) => {
2020
2000
  return res.json(updated);
2021
2001
  });
2022
2002
  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,
2003
+ const result = submitProjectChatSessionMessage({
2004
+ projectId: req.params.id,
2005
+ chatSessionId: req.params.sessionId,
2006
+ payload: req.body,
2007
+ broadcaster: io,
2051
2008
  });
2052
- if (!accepted) {
2053
- return res.status(409).json({ error: 'This chat session is already running.' });
2009
+ if (!result.ok) {
2010
+ return res.status(result.status).json({ error: result.error });
2054
2011
  }
2055
- const updated = getProjectChatSession(project.id, existing.id);
2056
- return res.json(updated);
2012
+ return res.status(result.status).json(result.value);
2057
2013
  });
2058
2014
  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.' });
2015
+ const result = await stopProjectChatSessionResponse({
2016
+ projectId: req.params.id,
2017
+ chatSessionId: req.params.sessionId,
2018
+ });
2019
+ if (!result.ok) {
2020
+ return res.status(result.status).json({ error: result.error });
2070
2021
  }
2071
- return res.json({ ok: true });
2022
+ return res.status(result.status).json(result.value);
2072
2023
  });
2073
2024
  app.get('/projects/:id/chat-sessions/:sessionId/diff', (req, res) => {
2074
2025
  const project = getProjectById(req.params.id);
@@ -4364,6 +4315,18 @@ app.patch('/tickets/:id', (req, res) => {
4364
4315
  const hasVariantField = hasOwn(req.body, 'variant');
4365
4316
  const hasSkillsField = hasOwn(req.body, 'skills');
4366
4317
  const isSettingsOnlyUpdate = !hasTitleField && !hasDescriptionField && !hasProjectField && !hasBundleField && (hasModelField || hasVariantField || hasSkillsField);
4318
+ if (isTicketAgentSettingsPayload(req.body)) {
4319
+ const result = updateTicketAgentSettings({
4320
+ ticketId: req.params.id,
4321
+ requestedProjectId: getRequestedProjectId(req),
4322
+ payload: req.body,
4323
+ broadcaster: io,
4324
+ });
4325
+ if (!result.ok) {
4326
+ return res.status(result.status).json({ error: result.error });
4327
+ }
4328
+ return res.status(result.status).json(result.value);
4329
+ }
4367
4330
  if (!isPlanTicket && !isSettingsOnlyUpdate) {
4368
4331
  return res.status(400).json({ error: 'Only plan tickets can be fully edited after work begins' });
4369
4332
  }
@@ -4481,79 +4444,21 @@ app.patch('/tickets/:id', (req, res) => {
4481
4444
  res.json(updated);
4482
4445
  });
4483
4446
  app.patch('/tickets/:id/state', (req, res) => {
4484
- const state = typeof req.body.state === 'string' ? req.body.state : '';
4485
- const validStates = ['plan', 'build', 'review'];
4486
- if (!validStates.includes(state)) {
4487
- return res.status(400).json({ error: 'Invalid state' });
4488
- }
4489
- const existing = getTicket(req.params.id);
4490
- if (!existing) {
4491
- return res.status(404).json({ error: 'Not found' });
4492
- }
4493
- if (!matchesTicketProjectContext(existing, req)) {
4494
- return res.status(404).json({ error: 'Not found' });
4495
- }
4496
- if (state === 'plan' && existing.state !== 'plan') {
4497
- return res.status(409).json({ error: 'Tickets cannot be moved back to plan after work begins' });
4498
- }
4499
- if (state === 'build') {
4500
- const buildBlocker = getBundleBuildBlockerResponse(existing);
4501
- if (buildBlocker) {
4502
- return res.status(409).json(buildBlocker);
4503
- }
4504
- const buildTransition = prepareTicketForBuild(existing.id);
4505
- if (!buildTransition.ok) {
4506
- return res.status(buildTransition.status).json({
4507
- error: buildTransition.error,
4508
- ...(buildTransition.blockers ? { blockers: buildTransition.blockers } : {}),
4509
- });
4510
- }
4511
- io.emit('ticket:updated', buildTransition.ticket);
4512
- res.json(buildTransition.ticket);
4513
- if (buildTransition.ticket.project_id && buildTransition.ticket.worktree_bundle_id) {
4514
- const queuedBuild = enqueueBundledBuild({
4515
- ticketId: buildTransition.ticket.id,
4516
- projectId: buildTransition.ticket.project_id,
4517
- worktreeBundleId: buildTransition.ticket.worktree_bundle_id,
4518
- activate: shouldStartBundleBuildNow(buildTransition.ticket),
4519
- });
4520
- if (!queuedBuild.shouldStartNow) {
4521
- return;
4522
- }
4523
- }
4524
- startBuild(buildTransition.ticket, io);
4525
- return;
4526
- }
4527
- if (state === 'review' && (existing.state !== 'build' || existing.agent_status !== 'done')) {
4528
- return res.status(409).json({ error: 'Tickets can only be moved to review from build when done' });
4529
- }
4530
- const project = getEffectiveProject(existing);
4531
- if (state === 'review' && !project) {
4532
- return res.status(400).json({ error: 'Ticket project no longer exists' });
4533
- }
4534
- if (state === 'review' && existing.project_id && existing.worktree_bundle_id) {
4535
- const reviewResult = reviewBundleTickets(existing.project_id, existing.worktree_bundle_id);
4536
- if (!reviewResult.ok) {
4537
- return res.status(reviewResult.status).json({ error: reviewResult.error });
4538
- }
4539
- const updated = reviewResult.details.tickets.find(ticket => ticket.id === existing.id);
4540
- if (!updated) {
4541
- return res.status(409).json({ error: 'Unable to update the requested ticket in this bundle' });
4542
- }
4543
- return res.json(updated);
4447
+ const result = transitionTicketState({
4448
+ ticketId: req.params.id,
4449
+ requestedState: req.body.state,
4450
+ requestedProjectId: getRequestedProjectId(req),
4451
+ broadcaster: io,
4452
+ reviewBundleTickets,
4453
+ });
4454
+ if (!result.ok) {
4455
+ return res.status(result.status).json({
4456
+ error: result.error,
4457
+ ...result.details,
4458
+ });
4544
4459
  }
4545
- const nextAgentStatus = state === 'review' ? null : existing.agent_status;
4546
- const nextAgentLog = state === 'review' ? null : existing.agent_log;
4547
- const nextSessionId = existing.session_id;
4548
- 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);
4549
- const ticket = autoParkTicketIfStale(req.params.id);
4550
- if (state === 'review' && ticket) {
4551
- startReview(ticket, io);
4552
- const runningReview = getTicket(req.params.id);
4553
- return res.json(runningReview);
4554
- }
4555
- io.emit('ticket:updated', ticket);
4556
- res.json(ticket);
4460
+ res.status(result.status).json(result.value);
4461
+ result.afterResponse?.();
4557
4462
  });
4558
4463
  app.patch('/tickets/:id/park', (req, res) => {
4559
4464
  const ticket = getTicket(req.params.id);
@@ -4755,41 +4660,19 @@ app.post('/tickets/:id/undo', (req, res) => {
4755
4660
  return res.json(result.ticket);
4756
4661
  });
4757
4662
  app.post('/tickets/:id/run', (req, res) => {
4758
- const ticket = getTicket(req.params.id);
4759
- if (!ticket) {
4760
- return res.status(404).json({ error: 'Not found' });
4761
- }
4762
- if (!matchesTicketProjectContext(ticket, req)) {
4763
- return res.status(404).json({ error: 'Not found' });
4764
- }
4765
- const project = getEffectiveProject(ticket);
4766
- if (!project) {
4767
- return res.status(400).json({ error: 'Ticket project no longer exists' });
4768
- }
4769
- const workspace = resolveTicketGitWorkspace(ticket);
4770
- if ('error' in workspace) {
4771
- return res.status(workspace.status).json({ error: workspace.error });
4772
- }
4773
- if (!hasRunConfig(project)) {
4774
- return res.status(400).json({ error: 'No run config' });
4775
- }
4776
- const runReadinessError = getTicketRunReadinessError(ticket, project);
4777
- if (runReadinessError) {
4778
- return res.status(409).json({ error: runReadinessError });
4779
- }
4780
- if (isRunning(ticket)) {
4781
- emitRunStateForContext(ticket, true);
4782
- const runningTicket = listTicketsInRunContext(ticket).find(entry => entry.id === ticket.id);
4783
- return res.json(runningTicket ?? { ...ticket, is_running: true, has_run_config: true });
4784
- }
4785
- const activeRunConflict = formatActiveRunConflict(ticket.project_id);
4786
- if (activeRunConflict) {
4787
- return res.status(409).json(activeRunConflict);
4663
+ const result = runTicketRun({
4664
+ ticketId: req.params.id,
4665
+ requestedProjectId: getRequestedProjectId(req),
4666
+ broadcaster: io,
4667
+ });
4668
+ if (!result.ok) {
4669
+ return res.status(result.status).json({
4670
+ error: result.error,
4671
+ ...result.details,
4672
+ });
4788
4673
  }
4789
- const running = { ...ticket, is_running: true, has_run_config: true };
4790
- emitRunStateForContext(ticket, true);
4791
- res.json(running);
4792
- runTicketInWorkspace(ticket, io, project, workspace.cwd);
4674
+ res.status(result.status).json(result.value);
4675
+ result.afterResponse?.();
4793
4676
  });
4794
4677
  app.post('/tickets/:id/open-ide', (req, res) => {
4795
4678
  const ticket = getTicket(req.params.id);
@@ -4982,46 +4865,17 @@ app.post('/projects/:id/open-ide', (req, res) => {
4982
4865
  return res.json({ ok: true });
4983
4866
  });
4984
4867
  app.post('/projects/:id/run', (req, res) => {
4985
- const project = getProjectById(req.params.id);
4986
- if (!project) {
4987
- return res.status(404).json({ error: 'Not found' });
4988
- }
4989
- if (!hasRunConfig(project)) {
4990
- return res.status(400).json({ error: 'No run config' });
4991
- }
4992
- const workspace = resolveProjectTargetGitWorkspace(project);
4993
- if ('error' in workspace) {
4994
- return res.status(workspace.status).json({ error: workspace.error });
4995
- }
4996
- const contextKey = workspace.run_context_key;
4997
- if (isRunning(contextKey)) {
4998
- return res.json({
4999
- active_run: {
5000
- kind: 'project',
5001
- context_key: contextKey,
5002
- project_id: project.id,
5003
- target_kind: workspace.target_kind,
5004
- target_bundle_id: workspace.target_bundle_id,
5005
- },
4868
+ const result = runProjectTarget({
4869
+ projectId: req.params.id,
4870
+ });
4871
+ if (!result.ok) {
4872
+ return res.status(result.status).json({
4873
+ error: result.error,
4874
+ ...result.details,
5006
4875
  });
5007
4876
  }
5008
- const activeRunConflict = formatActiveRunConflict(project.id);
5009
- if (activeRunConflict) {
5010
- return res.status(409).json(activeRunConflict);
5011
- }
5012
- const activeRun = {
5013
- kind: 'project',
5014
- context_key: contextKey,
5015
- project_id: project.id,
5016
- target_kind: workspace.target_kind,
5017
- target_bundle_id: workspace.target_bundle_id,
5018
- };
5019
- res.json({ active_run: activeRun });
5020
- void runProject(project, {
5021
- cwd: workspace.cwd,
5022
- targetKind: workspace.target_kind,
5023
- targetBundleId: workspace.target_bundle_id,
5024
- });
4877
+ res.status(result.status).json(result.value);
4878
+ result.afterResponse?.();
5025
4879
  });
5026
4880
  app.post('/projects/:id/test-sessions', (req, res) => {
5027
4881
  const project = getProjectById(req.params.id);
@@ -5204,154 +5058,69 @@ app.post('/terminals/:sessionId/destroy', (req, res) => {
5204
5058
  return res.status(204).send();
5205
5059
  });
5206
5060
  app.post('/tickets/:id/stop', async (req, res) => {
5207
- const ticket = getTicket(req.params.id);
5208
- if (!ticket) {
5209
- return res.status(404).json({ error: 'Not found' });
5210
- }
5211
- if (!matchesTicketProjectContext(ticket, req)) {
5212
- return res.status(404).json({ error: 'Not found' });
5061
+ const result = await stopTicketRun({
5062
+ ticketId: req.params.id,
5063
+ requestedProjectId: getRequestedProjectId(req),
5064
+ broadcaster: io,
5065
+ });
5066
+ if (!result.ok) {
5067
+ return res.status(result.status).json({ error: result.error });
5213
5068
  }
5214
- await stopTicket(ticket);
5215
- emitRunStateForContext(ticket, false);
5216
- const updated = listTicketsInRunContext(ticket).find(entry => entry.id === ticket.id) ?? { ...ticket, is_running: false };
5217
- res.json(updated);
5069
+ return res.status(result.status).json(result.value);
5218
5070
  });
5219
5071
  app.post('/projects/:id/stop-run', async (req, res) => {
5220
- const project = getProjectById(req.params.id);
5221
- if (!project) {
5222
- return res.status(404).json({ error: 'Not found' });
5223
- }
5224
- await stopProjectRun(project.id, {
5225
- kind: project.active_target_kind,
5226
- bundleId: project.active_target_bundle_id,
5072
+ const result = await stopProjectTargetRun({
5073
+ projectId: req.params.id,
5227
5074
  });
5228
- return res.json({ active_run: getActiveRunStatusForProject(project.id) });
5075
+ if (!result.ok) {
5076
+ return res.status(result.status).json({ error: result.error });
5077
+ }
5078
+ return res.status(result.status).json(result.value);
5229
5079
  });
5230
5080
  app.post('/tickets/:id/stop-build', async (req, res) => {
5231
- const ticket = getTicket(req.params.id);
5232
- if (!ticket) {
5233
- return res.status(404).json({ error: 'Not found' });
5234
- }
5235
- if (!matchesTicketProjectContext(ticket, req)) {
5236
- return res.status(404).json({ error: 'Not found' });
5237
- }
5238
- if (!canUseBuildControls(ticket)) {
5239
- return res.status(400).json({ error: 'Only build-stage and review follow-up tickets can stop builds' });
5240
- }
5241
- if (ticket.agent_status !== 'running') {
5242
- return res.status(400).json({ error: 'Ticket build is not running' });
5243
- }
5244
- if (!ticket.session_id) {
5245
- return res.status(400).json({ error: 'Build session is not ready to stop yet' });
5246
- }
5247
- if (!isBuildAgentRunning(ticket.id)) {
5248
- return res.status(409).json({ error: 'No active build process found for this ticket' });
5249
- }
5250
- await stopBuildAgent(ticket.id);
5251
- const updated = getTicket(req.params.id);
5252
- if (!updated) {
5253
- return res.status(404).json({ error: 'Not found' });
5081
+ const result = await stopBuildTicketRun({
5082
+ ticketId: req.params.id,
5083
+ requestedProjectId: getRequestedProjectId(req),
5084
+ });
5085
+ if (!result.ok) {
5086
+ return res.status(result.status).json({ error: result.error });
5254
5087
  }
5255
- res.json(updated);
5088
+ return res.status(result.status).json(result.value);
5256
5089
  });
5257
5090
  app.post('/tickets/:id/resume-build', (req, res) => {
5258
- const existing = getTicket(req.params.id);
5259
- if (!existing) {
5260
- return res.status(404).json({ error: 'Not found' });
5261
- }
5262
- if (!matchesTicketProjectContext(existing, req)) {
5263
- return res.status(404).json({ error: 'Not found' });
5264
- }
5265
- if (!canUseBuildControls(existing)) {
5266
- return res.status(400).json({ error: 'Only build-stage and review follow-up tickets can resume builds' });
5267
- }
5268
- if (existing.agent_status !== 'stopped') {
5269
- return res.status(400).json({ error: 'Only manually stopped builds can be resumed' });
5270
- }
5271
- if (!existing.session_id) {
5272
- return res.status(400).json({ error: 'Build session is not available to resume' });
5273
- }
5274
- db.prepare('UPDATE tickets SET build_completed_at = NULL, auto_park_dismissed_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(existing.id);
5275
- const ticket = getTicket(existing.id);
5276
- if (!ticket) {
5277
- return res.status(404).json({ error: 'Not found' });
5091
+ const result = resumeBuildTicketRun({
5092
+ ticketId: req.params.id,
5093
+ requestedProjectId: getRequestedProjectId(req),
5094
+ broadcaster: io,
5095
+ });
5096
+ if (!result.ok) {
5097
+ return res.status(result.status).json({ error: result.error });
5278
5098
  }
5279
- resumeBuild(ticket, io);
5280
- res.json(ticket);
5099
+ return res.status(result.status).json(result.value);
5281
5100
  });
5282
5101
  app.post('/tickets/:id/follow-up', (req, res) => {
5283
- const ticket = getTicket(req.params.id);
5284
- if (!ticket) {
5285
- return res.status(404).json({ error: 'Not found' });
5286
- }
5287
- if (!matchesTicketProjectContext(ticket, req)) {
5288
- return res.status(404).json({ error: 'Not found' });
5289
- }
5290
- if (ticket.state === 'plan') {
5291
- return res.status(400).json({ error: 'Only non-plan tickets can accept follow-up prompts' });
5292
- }
5293
- if (ticket.agent_status === 'running') {
5294
- return res.status(409).json({ error: 'Ticket is already running' });
5295
- }
5296
- const instruction = typeof req.body?.instruction === 'string'
5297
- ? req.body.instruction.trim()
5298
- : '';
5299
- if (!instruction) {
5300
- return res.status(400).json({ error: 'instruction is required' });
5301
- }
5302
- const project = getEffectiveProject(ticket);
5303
- if (!project) {
5304
- return res.status(400).json({ error: 'Ticket project no longer exists' });
5305
- }
5306
- const branch = resolveTicketBranch(ticket);
5307
- if (!branch || ticket.has_worktree === false || !isWorktreeReady(branch, project)) {
5308
- return res.status(400).json({ error: 'Worktree not ready yet' });
5309
- }
5310
- createTicketMessage({
5311
- ticketId: ticket.id,
5312
- role: 'user',
5313
- kind: 'follow_up',
5314
- content: instruction,
5102
+ const result = submitTicketFollowUp({
5103
+ ticketId: req.params.id,
5104
+ requestedProjectId: getRequestedProjectId(req),
5105
+ payload: req.body,
5106
+ broadcaster: io,
5315
5107
  });
5316
- const started = startFollowUp(ticket, io, instruction);
5317
- if (!started) {
5318
- return res.status(409).json({ error: 'Ticket is already running' });
5108
+ if (!result.ok) {
5109
+ return res.status(result.status).json({ error: result.error });
5319
5110
  }
5320
- const updated = getTicket(ticket.id);
5321
- return res.json(updated ?? ticket);
5111
+ return res.status(result.status).json(result.value);
5322
5112
  });
5323
5113
  app.post('/tickets/:id/plan-follow-up', (req, res) => {
5324
- const ticket = getTicket(req.params.id);
5325
- if (!ticket) {
5326
- return res.status(404).json({ error: 'Not found' });
5327
- }
5328
- if (!matchesTicketProjectContext(ticket, req)) {
5329
- return res.status(404).json({ error: 'Not found' });
5330
- }
5331
- if (ticket.state !== 'plan') {
5332
- return res.status(400).json({ error: 'Only plan tickets can accept planning follow-up prompts' });
5333
- }
5334
- if (ticket.agent_status === 'running') {
5335
- return res.status(409).json({ error: 'Ticket is already running' });
5336
- }
5337
- const instruction = typeof req.body?.instruction === 'string'
5338
- ? req.body.instruction.trim()
5339
- : '';
5340
- if (!instruction) {
5341
- return res.status(400).json({ error: 'instruction is required' });
5342
- }
5343
- createTicketMessage({
5344
- ticketId: ticket.id,
5345
- role: 'user',
5346
- kind: 'planning_follow_up',
5347
- content: instruction,
5114
+ const result = submitPlanTicketFollowUp({
5115
+ ticketId: req.params.id,
5116
+ requestedProjectId: getRequestedProjectId(req),
5117
+ payload: req.body,
5118
+ broadcaster: io,
5348
5119
  });
5349
- const started = startPlanFollowUp(ticket, io, instruction);
5350
- if (!started) {
5351
- return res.status(409).json({ error: 'Ticket is already running' });
5120
+ if (!result.ok) {
5121
+ return res.status(result.status).json({ error: result.error });
5352
5122
  }
5353
- const updated = getTicket(ticket.id);
5354
- return res.json(updated ?? ticket);
5123
+ return res.status(result.status).json(result.value);
5355
5124
  });
5356
5125
  app.post('/tickets/:id/plan', (req, res) => {
5357
5126
  const ticket = getTicket(req.params.id);
@@ -5430,26 +5199,20 @@ io.on('connection', (socket) => {
5430
5199
  registerTerminalSocketHandlers(io, socket);
5431
5200
  });
5432
5201
  const DEFAULT_PORT = 3001;
5433
- const resolveListeningHost = (host) => {
5434
- if (!host || host === '0.0.0.0' || host === '::') {
5435
- return 'localhost';
5436
- }
5437
- return host;
5438
- };
5439
5202
  const resolveStartResult = (host) => {
5440
5203
  const address = httpServer.address();
5441
- const listeningHost = resolveListeningHost(host);
5204
+ const { displayHost } = resolveDesktopServerHost(host);
5442
5205
  if (!address || typeof address === 'string') {
5443
5206
  return {
5444
- host: listeningHost,
5207
+ host: displayHost,
5445
5208
  port: DEFAULT_PORT,
5446
- url: `http://${listeningHost}:${DEFAULT_PORT}`,
5209
+ url: `http://${displayHost}:${DEFAULT_PORT}`,
5447
5210
  };
5448
5211
  }
5449
5212
  return {
5450
- host: listeningHost,
5213
+ host: displayHost,
5451
5214
  port: address.port,
5452
- url: `http://${listeningHost}:${address.port}`,
5215
+ url: `http://${displayHost}:${address.port}`,
5453
5216
  };
5454
5217
  };
5455
5218
  export const startServer = ({ port = DEFAULT_PORT, host } = {}) => {
@@ -5467,14 +5230,12 @@ export const startServer = ({ port = DEFAULT_PORT, host } = {}) => {
5467
5230
  resolve(resolveStartResult(host));
5468
5231
  };
5469
5232
  httpServer.once('error', handleError);
5470
- if (host) {
5471
- httpServer.listen(port, host, handleListening);
5472
- return;
5473
- }
5474
- httpServer.listen(port, handleListening);
5233
+ const { bindHost } = resolveDesktopServerHost(host);
5234
+ httpServer.listen(port, bindHost, handleListening);
5475
5235
  });
5476
5236
  };
5477
5237
  export const shutdownServer = async (signal) => {
5238
+ await stopMobileAccess();
5478
5239
  await clearAllTestSessions(io);
5479
5240
  await shutdownRealtimeServer({
5480
5241
  signal,