@straiffi/archon 1.1.3 → 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 +199 -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 +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-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,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);
@@ -4364,6 +4308,18 @@ app.patch('/tickets/:id', (req, res) => {
4364
4308
  const hasVariantField = hasOwn(req.body, 'variant');
4365
4309
  const hasSkillsField = hasOwn(req.body, 'skills');
4366
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
+ }
4367
4323
  if (!isPlanTicket && !isSettingsOnlyUpdate) {
4368
4324
  return res.status(400).json({ error: 'Only plan tickets can be fully edited after work begins' });
4369
4325
  }
@@ -4481,79 +4437,21 @@ app.patch('/tickets/:id', (req, res) => {
4481
4437
  res.json(updated);
4482
4438
  });
4483
4439
  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);
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
+ });
4544
4452
  }
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);
4453
+ res.status(result.status).json(result.value);
4454
+ result.afterResponse?.();
4557
4455
  });
4558
4456
  app.patch('/tickets/:id/park', (req, res) => {
4559
4457
  const ticket = getTicket(req.params.id);
@@ -4755,41 +4653,19 @@ app.post('/tickets/:id/undo', (req, res) => {
4755
4653
  return res.json(result.ticket);
4756
4654
  });
4757
4655
  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);
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
+ });
4788
4666
  }
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);
4667
+ res.status(result.status).json(result.value);
4668
+ result.afterResponse?.();
4793
4669
  });
4794
4670
  app.post('/tickets/:id/open-ide', (req, res) => {
4795
4671
  const ticket = getTicket(req.params.id);
@@ -4982,46 +4858,17 @@ app.post('/projects/:id/open-ide', (req, res) => {
4982
4858
  return res.json({ ok: true });
4983
4859
  });
4984
4860
  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
- },
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,
5006
4868
  });
5007
4869
  }
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
- });
4870
+ res.status(result.status).json(result.value);
4871
+ result.afterResponse?.();
5025
4872
  });
5026
4873
  app.post('/projects/:id/test-sessions', (req, res) => {
5027
4874
  const project = getProjectById(req.params.id);
@@ -5204,154 +5051,69 @@ app.post('/terminals/:sessionId/destroy', (req, res) => {
5204
5051
  return res.status(204).send();
5205
5052
  });
5206
5053
  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' });
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 });
5213
5061
  }
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);
5062
+ return res.status(result.status).json(result.value);
5218
5063
  });
5219
5064
  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,
5065
+ const result = await stopProjectTargetRun({
5066
+ projectId: req.params.id,
5227
5067
  });
5228
- 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);
5229
5072
  });
5230
5073
  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' });
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 });
5254
5080
  }
5255
- res.json(updated);
5081
+ return res.status(result.status).json(result.value);
5256
5082
  });
5257
5083
  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' });
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 });
5278
5091
  }
5279
- resumeBuild(ticket, io);
5280
- res.json(ticket);
5092
+ return res.status(result.status).json(result.value);
5281
5093
  });
5282
5094
  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,
5095
+ const result = submitTicketFollowUp({
5096
+ ticketId: req.params.id,
5097
+ requestedProjectId: getRequestedProjectId(req),
5098
+ payload: req.body,
5099
+ broadcaster: io,
5315
5100
  });
5316
- const started = startFollowUp(ticket, io, instruction);
5317
- if (!started) {
5318
- return res.status(409).json({ error: 'Ticket is already running' });
5101
+ if (!result.ok) {
5102
+ return res.status(result.status).json({ error: result.error });
5319
5103
  }
5320
- const updated = getTicket(ticket.id);
5321
- return res.json(updated ?? ticket);
5104
+ return res.status(result.status).json(result.value);
5322
5105
  });
5323
5106
  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,
5107
+ const result = submitPlanTicketFollowUp({
5108
+ ticketId: req.params.id,
5109
+ requestedProjectId: getRequestedProjectId(req),
5110
+ payload: req.body,
5111
+ broadcaster: io,
5348
5112
  });
5349
- const started = startPlanFollowUp(ticket, io, instruction);
5350
- if (!started) {
5351
- return res.status(409).json({ error: 'Ticket is already running' });
5113
+ if (!result.ok) {
5114
+ return res.status(result.status).json({ error: result.error });
5352
5115
  }
5353
- const updated = getTicket(ticket.id);
5354
- return res.json(updated ?? ticket);
5116
+ return res.status(result.status).json(result.value);
5355
5117
  });
5356
5118
  app.post('/tickets/:id/plan', (req, res) => {
5357
5119
  const ticket = getTicket(req.params.id);
@@ -5430,26 +5192,20 @@ io.on('connection', (socket) => {
5430
5192
  registerTerminalSocketHandlers(io, socket);
5431
5193
  });
5432
5194
  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
5195
  const resolveStartResult = (host) => {
5440
5196
  const address = httpServer.address();
5441
- const listeningHost = resolveListeningHost(host);
5197
+ const { displayHost } = resolveDesktopServerHost(host);
5442
5198
  if (!address || typeof address === 'string') {
5443
5199
  return {
5444
- host: listeningHost,
5200
+ host: displayHost,
5445
5201
  port: DEFAULT_PORT,
5446
- url: `http://${listeningHost}:${DEFAULT_PORT}`,
5202
+ url: `http://${displayHost}:${DEFAULT_PORT}`,
5447
5203
  };
5448
5204
  }
5449
5205
  return {
5450
- host: listeningHost,
5206
+ host: displayHost,
5451
5207
  port: address.port,
5452
- url: `http://${listeningHost}:${address.port}`,
5208
+ url: `http://${displayHost}:${address.port}`,
5453
5209
  };
5454
5210
  };
5455
5211
  export const startServer = ({ port = DEFAULT_PORT, host } = {}) => {
@@ -5467,14 +5223,12 @@ export const startServer = ({ port = DEFAULT_PORT, host } = {}) => {
5467
5223
  resolve(resolveStartResult(host));
5468
5224
  };
5469
5225
  httpServer.once('error', handleError);
5470
- if (host) {
5471
- httpServer.listen(port, host, handleListening);
5472
- return;
5473
- }
5474
- httpServer.listen(port, handleListening);
5226
+ const { bindHost } = resolveDesktopServerHost(host);
5227
+ httpServer.listen(port, bindHost, handleListening);
5475
5228
  });
5476
5229
  };
5477
5230
  export const shutdownServer = async (signal) => {
5231
+ await stopMobileAccess();
5478
5232
  await clearAllTestSessions(io);
5479
5233
  await shutdownRealtimeServer({
5480
5234
  signal,