thepopebot 1.2.76-beta.2 → 1.2.76-beta.21

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 (128) hide show
  1. package/README.md +3 -3
  2. package/api/CLAUDE.md +11 -4
  3. package/api/index.js +56 -18
  4. package/bin/CLAUDE.md +7 -4
  5. package/bin/cli.js +25 -45
  6. package/config/CLAUDE.md +23 -4
  7. package/drizzle/0021_coding_agent_workspace.sql +1 -0
  8. package/drizzle/0022_organic_apocalypse.sql +16 -0
  9. package/drizzle/0023_needy_ender_wiggin.sql +1 -0
  10. package/drizzle/meta/0021_snapshot.json +639 -0
  11. package/drizzle/meta/0022_snapshot.json +743 -0
  12. package/drizzle/meta/0023_snapshot.json +750 -0
  13. package/drizzle/meta/_journal.json +21 -0
  14. package/lib/CLAUDE.md +2 -2
  15. package/lib/actions.js +9 -1
  16. package/lib/ai/CLAUDE.md +72 -57
  17. package/lib/ai/helper-llm.js +108 -0
  18. package/lib/ai/index.js +308 -438
  19. package/lib/ai/line-mappers.js +42 -24
  20. package/lib/ai/scope.js +26 -0
  21. package/lib/ai/sdk-adapters/CLAUDE.md +114 -0
  22. package/lib/ai/sdk-adapters/claude-code.js +120 -8
  23. package/lib/ai/system-prompt.js +34 -0
  24. package/lib/ai/workspace-setup.js +19 -35
  25. package/lib/channels/CLAUDE.md +14 -4
  26. package/lib/channels/base.js +6 -2
  27. package/lib/channels/commands/index.js +42 -0
  28. package/lib/channels/commands/session.js +53 -0
  29. package/lib/channels/commands/verify.js +18 -0
  30. package/lib/channels/telegram.js +79 -28
  31. package/lib/chat/CLAUDE.md +4 -4
  32. package/lib/chat/actions.js +270 -49
  33. package/lib/chat/api.js +185 -31
  34. package/lib/chat/components/CLAUDE.md +6 -2
  35. package/lib/chat/components/chat-input.js +77 -47
  36. package/lib/chat/components/chat-input.jsx +77 -40
  37. package/lib/chat/components/chat-page.js +2 -0
  38. package/lib/chat/components/chat-page.jsx +3 -0
  39. package/lib/chat/components/chat.js +62 -14
  40. package/lib/chat/components/chat.jsx +68 -10
  41. package/lib/chat/components/code-mode-toggle.js +141 -22
  42. package/lib/chat/components/code-mode-toggle.jsx +129 -20
  43. package/lib/chat/components/containers-page.js +58 -40
  44. package/lib/chat/components/containers-page.jsx +64 -25
  45. package/lib/chat/components/crons-page.js +17 -3
  46. package/lib/chat/components/crons-page.jsx +34 -6
  47. package/lib/chat/components/index.js +2 -2
  48. package/lib/chat/components/message.js +18 -3
  49. package/lib/chat/components/message.jsx +18 -3
  50. package/lib/chat/components/profile-page.js +182 -4
  51. package/lib/chat/components/profile-page.jsx +196 -1
  52. package/lib/chat/components/scope-picker.js +21 -0
  53. package/lib/chat/components/scope-picker.jsx +27 -0
  54. package/lib/chat/components/settings-chat-page.js +11 -11
  55. package/lib/chat/components/settings-chat-page.jsx +14 -18
  56. package/lib/chat/components/settings-coding-agents-page.js +110 -16
  57. package/lib/chat/components/settings-coding-agents-page.jsx +87 -3
  58. package/lib/chat/components/settings-github-page.js +5 -0
  59. package/lib/chat/components/settings-github-page.jsx +5 -0
  60. package/lib/chat/components/settings-layout.js +3 -3
  61. package/lib/chat/components/settings-layout.jsx +3 -3
  62. package/lib/chat/components/settings-secrets-layout.js +1 -2
  63. package/lib/chat/components/settings-secrets-layout.jsx +1 -2
  64. package/lib/chat/components/settings-secrets-page.js +180 -75
  65. package/lib/chat/components/settings-secrets-page.jsx +212 -66
  66. package/lib/chat/components/triggers-page.js +17 -3
  67. package/lib/chat/components/triggers-page.jsx +34 -6
  68. package/lib/chat/components/ui/combobox.js +18 -2
  69. package/lib/chat/components/ui/combobox.jsx +17 -1
  70. package/lib/chat/components/ui/dropdown-menu.js +23 -2
  71. package/lib/chat/components/ui/dropdown-menu.jsx +27 -2
  72. package/lib/chat/telegram-profile.js +33 -0
  73. package/lib/cluster/CLAUDE.md +9 -3
  74. package/lib/code/CLAUDE.md +11 -3
  75. package/lib/code/actions.js +47 -8
  76. package/lib/code/terminal-view.js +31 -21
  77. package/lib/code/terminal-view.jsx +32 -23
  78. package/lib/config.js +15 -4
  79. package/lib/containers/CLAUDE.md +16 -6
  80. package/lib/db/CLAUDE.md +5 -2
  81. package/lib/db/chats.js +9 -17
  82. package/lib/db/code-workspaces.js +8 -3
  83. package/lib/db/config.js +0 -1
  84. package/lib/db/index.js +12 -0
  85. package/lib/db/schema.js +24 -1
  86. package/lib/db/user-channels.js +129 -0
  87. package/lib/llm-providers.js +8 -0
  88. package/lib/maintenance.js +31 -21
  89. package/lib/tools/CLAUDE.md +12 -3
  90. package/lib/tools/assemblyai.js +17 -0
  91. package/lib/tools/create-agent-job.js +12 -8
  92. package/lib/tools/docker.js +34 -10
  93. package/lib/tools/github.js +34 -0
  94. package/lib/tools/telegram.js +106 -0
  95. package/lib/utils/render-md.js +44 -18
  96. package/package.json +8 -8
  97. package/setup/CLAUDE.md +11 -5
  98. package/setup/lib/providers.mjs +2 -1
  99. package/setup/lib/targets.mjs +13 -16
  100. package/setup/lib/telegram.mjs +8 -69
  101. package/templates/.env.example +0 -7
  102. package/templates/.github/workflows/rebuild-event-handler.yml +1 -1
  103. package/templates/.gitignore.template +1 -3
  104. package/templates/CLAUDE.md +1 -1
  105. package/templates/CLAUDE.md.template +29 -7
  106. package/templates/agent-job/CLAUDE.md.template +5 -3
  107. package/templates/agent-job/CRONS.json +16 -0
  108. package/templates/agent-job/SYSTEM.md +16 -11
  109. package/templates/agents/CLAUDE.md.template +17 -17
  110. package/templates/coding-workspace/CLAUDE.md.template +7 -0
  111. package/templates/data/CLAUDE.md.template +1 -1
  112. package/templates/docker-compose.custom.yml +1 -0
  113. package/templates/docker-compose.yml +1 -0
  114. package/templates/event-handler/CLAUDE.md.template +79 -0
  115. package/templates/event-handler/TRIGGERS.json +18 -2
  116. package/templates/skills/CLAUDE.md.template +20 -22
  117. package/templates/skills/{library/agent-job-secrets → agent-job-secrets}/SKILL.md +2 -2
  118. package/lib/ai/agent.js +0 -65
  119. package/lib/ai/async-channel.js +0 -51
  120. package/lib/ai/model.js +0 -130
  121. package/lib/ai/tools.js +0 -164
  122. package/lib/tools/openai.js +0 -37
  123. package/setup/lib/telegram-verify.mjs +0 -63
  124. package/setup/setup-telegram.mjs +0 -260
  125. package/templates/agent-job/SOUL.md +0 -17
  126. /package/templates/{skills/active/.gitkeep → coding-workspace/SYSTEM.md} +0 -0
  127. /package/templates/skills/{library/agent-job-secrets → agent-job-secrets}/agent-job-secrets.js +0 -0
  128. /package/templates/skills/{library/playwright-cli → playwright-cli}/SKILL.md +0 -0
@@ -55,12 +55,32 @@ function ActionCard({ action, index }) {
55
55
  <pre className="text-xs bg-muted rounded-md p-3 whitespace-pre-wrap break-words font-mono overflow-auto max-h-48">
56
56
  {action.job}
57
57
  </pre>
58
- {(action.llm_provider || action.llm_model) && (
59
- <div className="flex items-center gap-2 mt-2">
60
- <span className="text-xs font-medium text-muted-foreground">LLM:</span>
61
- <span className="inline-flex items-center rounded-full bg-purple-500/10 text-purple-500 px-2 py-0.5 text-[10px] font-medium">
62
- {[action.llm_provider, action.llm_model].filter(Boolean).join(' / ')}
63
- </span>
58
+ {(action.agent_backend || action.llm_model || action.scope) && (
59
+ <div className="flex items-center gap-2 mt-2 flex-wrap">
60
+ {action.agent_backend && (
61
+ <>
62
+ <span className="text-xs font-medium text-muted-foreground">Agent:</span>
63
+ <span className="inline-flex items-center rounded-full bg-purple-500/10 text-purple-500 px-2 py-0.5 text-[10px] font-medium">
64
+ {action.agent_backend}
65
+ </span>
66
+ </>
67
+ )}
68
+ {action.llm_model && (
69
+ <>
70
+ <span className="text-xs font-medium text-muted-foreground">Model:</span>
71
+ <span className="inline-flex items-center rounded-full bg-purple-500/10 text-purple-500 px-2 py-0.5 text-[10px] font-medium">
72
+ {action.llm_model}
73
+ </span>
74
+ </>
75
+ )}
76
+ {action.scope && (
77
+ <>
78
+ <span className="text-xs font-medium text-muted-foreground">Scope:</span>
79
+ <span className="inline-flex items-center rounded-full bg-purple-500/10 text-purple-500 px-2 py-0.5 text-[10px] font-mono">
80
+ {action.scope}
81
+ </span>
82
+ </>
83
+ )}
64
84
  </div>
65
85
  )}
66
86
  </div>
@@ -83,6 +103,14 @@ function ActionCard({ action, index }) {
83
103
  </pre>
84
104
  </div>
85
105
  )}
106
+ {action.headers && Object.keys(action.headers).length > 0 && (
107
+ <div>
108
+ <p className="text-xs font-medium text-muted-foreground mb-1">Headers</p>
109
+ <pre className="text-xs bg-muted rounded-md p-3 whitespace-pre-wrap break-words font-mono overflow-auto max-h-48">
110
+ {JSON.stringify(action.headers, null, 2)}
111
+ </pre>
112
+ </div>
113
+ )}
86
114
  </div>
87
115
  )}
88
116
  </div>
@@ -3,7 +3,7 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useState, useRef, useEffect, useCallback } from "react";
4
4
  import { ChevronDownIcon, CheckIcon, SearchIcon } from "../icons.js";
5
5
  import { cn } from "../../utils.js";
6
- function Combobox({ options = [], value, onChange, placeholder = "Select...", loading = false, disabled = false, highlight = false, side = "bottom", onOpen, triggerClassName, triggerLabel }) {
6
+ function Combobox({ options = [], value, onChange, placeholder = "Select...", loading = false, disabled = false, highlight = false, side = "bottom", onOpen, triggerClassName, triggerLabel, footerAction }) {
7
7
  const [open, setOpen] = useState(false);
8
8
  const [filter, setFilter] = useState("");
9
9
  const ref = useRef(null);
@@ -93,7 +93,23 @@ function Combobox({ options = [], value, onChange, placeholder = "Select...", lo
93
93
  ]
94
94
  },
95
95
  opt.value
96
- )) })
96
+ )) }),
97
+ footerAction && /* @__PURE__ */ jsx("div", { className: "border-t border-border p-1", children: /* @__PURE__ */ jsxs(
98
+ "button",
99
+ {
100
+ type: "button",
101
+ onClick: () => {
102
+ setOpen(false);
103
+ setFilter("");
104
+ footerAction.onClick();
105
+ },
106
+ className: "flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-sm text-left transition-colors hover:bg-accent text-muted-foreground",
107
+ children: [
108
+ footerAction.icon,
109
+ /* @__PURE__ */ jsx("span", { children: footerAction.label })
110
+ ]
111
+ }
112
+ ) })
97
113
  ] })
98
114
  ] });
99
115
  }
@@ -4,7 +4,7 @@ import { useState, useRef, useEffect, useCallback } from 'react';
4
4
  import { ChevronDownIcon, CheckIcon, SearchIcon } from '../icons.js';
5
5
  import { cn } from '../../utils.js';
6
6
 
7
- export function Combobox({ options = [], value, onChange, placeholder = 'Select...', loading = false, disabled = false, highlight = false, side = 'bottom', onOpen, triggerClassName, triggerLabel }) {
7
+ export function Combobox({ options = [], value, onChange, placeholder = 'Select...', loading = false, disabled = false, highlight = false, side = 'bottom', onOpen, triggerClassName, triggerLabel, footerAction }) {
8
8
  const [open, setOpen] = useState(false);
9
9
  const [filter, setFilter] = useState('');
10
10
  const ref = useRef(null);
@@ -117,6 +117,22 @@ export function Combobox({ options = [], value, onChange, placeholder = 'Select.
117
117
  ))
118
118
  )}
119
119
  </div>
120
+ {footerAction && (
121
+ <div className="border-t border-border p-1">
122
+ <button
123
+ type="button"
124
+ onClick={() => {
125
+ setOpen(false);
126
+ setFilter('');
127
+ footerAction.onClick();
128
+ }}
129
+ className="flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-sm text-left transition-colors hover:bg-accent text-muted-foreground"
130
+ >
131
+ {footerAction.icon}
132
+ <span>{footerAction.label}</span>
133
+ </button>
134
+ </div>
135
+ )}
120
136
  </div>
121
137
  )}
122
138
  </div>
@@ -30,11 +30,30 @@ function DropdownMenuContent({ children, className, align = "start", side = "bot
30
30
  const updatePosition = useCallback(() => {
31
31
  if (!triggerRef.current) return;
32
32
  const rect = triggerRef.current.getBoundingClientRect();
33
+ const margin = 8;
34
+ let left = align === "start" ? rect.left : void 0;
35
+ let right = align === "end" ? window.innerWidth - rect.right : void 0;
36
+ const menuWidth = ref.current?.getBoundingClientRect().width || 0;
37
+ if (menuWidth > 0) {
38
+ if (align === "end") {
39
+ const computedLeft = rect.right - menuWidth;
40
+ if (computedLeft < margin) {
41
+ right = void 0;
42
+ left = margin;
43
+ }
44
+ } else {
45
+ const computedRight = rect.left + menuWidth;
46
+ if (computedRight > window.innerWidth - margin) {
47
+ left = void 0;
48
+ right = margin;
49
+ }
50
+ }
51
+ }
33
52
  setPos({
34
53
  top: side === "bottom" ? rect.bottom + sideOffset : void 0,
35
54
  bottom: side === "top" ? window.innerHeight - rect.top + sideOffset : void 0,
36
- left: align === "start" ? rect.left : void 0,
37
- right: align === "end" ? window.innerWidth - rect.right : void 0
55
+ left,
56
+ right
38
57
  });
39
58
  }, [triggerRef, side, align, sideOffset]);
40
59
  useEffect(() => {
@@ -43,6 +62,7 @@ function DropdownMenuContent({ children, className, align = "start", side = "bot
43
62
  return;
44
63
  }
45
64
  updatePosition();
65
+ const raf = requestAnimationFrame(updatePosition);
46
66
  const handleClickOutside = (e) => {
47
67
  if (ref.current && !ref.current.contains(e.target) && triggerRef.current && !triggerRef.current.contains(e.target)) {
48
68
  onOpenChange(false);
@@ -56,6 +76,7 @@ function DropdownMenuContent({ children, className, align = "start", side = "bot
56
76
  document.addEventListener("keydown", handleEsc);
57
77
  window.addEventListener("scroll", handleScroll, true);
58
78
  return () => {
79
+ cancelAnimationFrame(raf);
59
80
  document.removeEventListener("click", handleClickOutside);
60
81
  document.removeEventListener("keydown", handleEsc);
61
82
  window.removeEventListener("scroll", handleScroll, true);
@@ -47,17 +47,41 @@ export function DropdownMenuContent({ children, className, align = 'start', side
47
47
  const updatePosition = useCallback(() => {
48
48
  if (!triggerRef.current) return;
49
49
  const rect = triggerRef.current.getBoundingClientRect();
50
+ const margin = 8;
51
+ let left = align === 'start' ? rect.left : undefined;
52
+ let right = align === 'end' ? window.innerWidth - rect.right : undefined;
53
+
54
+ // Clamp horizontally so the menu never overflows the viewport.
55
+ const menuWidth = ref.current?.getBoundingClientRect().width || 0;
56
+ if (menuWidth > 0) {
57
+ if (align === 'end') {
58
+ const computedLeft = rect.right - menuWidth;
59
+ if (computedLeft < margin) {
60
+ right = undefined;
61
+ left = margin;
62
+ }
63
+ } else {
64
+ const computedRight = rect.left + menuWidth;
65
+ if (computedRight > window.innerWidth - margin) {
66
+ left = undefined;
67
+ right = margin;
68
+ }
69
+ }
70
+ }
71
+
50
72
  setPos({
51
73
  top: side === 'bottom' ? rect.bottom + sideOffset : undefined,
52
74
  bottom: side === 'top' ? window.innerHeight - rect.top + sideOffset : undefined,
53
- left: align === 'start' ? rect.left : undefined,
54
- right: align === 'end' ? window.innerWidth - rect.right : undefined,
75
+ left,
76
+ right,
55
77
  });
56
78
  }, [triggerRef, side, align, sideOffset]);
57
79
 
58
80
  useEffect(() => {
59
81
  if (!open) { setPos(null); return; }
60
82
  updatePosition();
83
+ // Re-run after render so menu width is measurable for clamping.
84
+ const raf = requestAnimationFrame(updatePosition);
61
85
  const handleClickOutside = (e) => {
62
86
  if (ref.current && !ref.current.contains(e.target) && triggerRef.current && !triggerRef.current.contains(e.target)) {
63
87
  onOpenChange(false);
@@ -71,6 +95,7 @@ export function DropdownMenuContent({ children, className, align = 'start', side
71
95
  document.addEventListener('keydown', handleEsc);
72
96
  window.addEventListener('scroll', handleScroll, true);
73
97
  return () => {
98
+ cancelAnimationFrame(raf);
74
99
  document.removeEventListener('click', handleClickOutside);
75
100
  document.removeEventListener('keydown', handleEsc);
76
101
  window.removeEventListener('scroll', handleScroll, true);
@@ -0,0 +1,33 @@
1
+ import { getUserChannel } from '../db/user-channels.js';
2
+ import { getConfig } from '../config.js';
3
+ import { validateBotToken } from '../tools/telegram.js';
4
+
5
+ /**
6
+ * Build the initial server-rendered state for the Telegram profile tab.
7
+ * Returns `{ status: 'unlinked' | 'pending' | 'verified', ...fields, botUsername }`.
8
+ */
9
+ export async function getTelegramProfileInitial(userId) {
10
+ const row = getUserChannel(userId, 'telegram');
11
+ const botToken = getConfig('TELEGRAM_BOT_TOKEN');
12
+ let botUsername = null;
13
+ if (botToken) {
14
+ const info = await validateBotToken(botToken);
15
+ if (info.valid) botUsername = info.botInfo.username;
16
+ }
17
+
18
+ if (!row) return { status: 'unlinked', botUsername };
19
+ if (row.verifiedAt) {
20
+ return {
21
+ status: 'verified',
22
+ verifiedAt: row.verifiedAt,
23
+ channelChatId: row.channelChatId,
24
+ botUsername,
25
+ };
26
+ }
27
+ return {
28
+ status: 'pending',
29
+ code: row.code,
30
+ expiresAt: row.codeExpiresAt,
31
+ botUsername,
32
+ };
33
+ }
@@ -41,9 +41,15 @@ Roles support multiple concurrent triggers. All paths use `canRunRole()` as a sh
41
41
 
42
42
  ## Concurrency & Validation
43
43
 
44
- `canRunRole(roleIdOrData)` is the shared gate function. It checks cluster enabled status and concurrency limits. Returns `{ allowed, reason?, roleData? }`. All trigger paths call this before `runClusterRole()`.
44
+ `canRunRole(roleIdOrData)` is the synchronous validation function. It checks cluster enabled status and concurrency limits. Returns `{ allowed, reason?, roleData? }`. Reasons: `disabled` (cluster off), `concurrency` (at max), `not_found`.
45
45
 
46
- Each role has `maxConcurrency` (default 1). `canRunRole()` counts running instances via `listContainers()`. Reasons: `disabled` (cluster off), `concurrency` (at max), `not_found`.
46
+ `acquireAndRunRole(roleId, payload?)` is the **atomic gate** that all trigger paths actually use. It calls `canRunRole()` and `runClusterRole()` together so two simultaneous triggers can't both pass the concurrency check before either container is observable to `listContainers()`. Manual UI triggers, webhooks, cron, and file-watch all funnel through this.
47
+
48
+ Each role has `maxConcurrency` (default 1). `canRunRole()` counts running instances via `listContainers()`.
49
+
50
+ ## Plan Mode (Roles)
51
+
52
+ `cluster_roles.planMode` (default `0`) gates the worker into Claude's plan-mode (read-only). When set, the worker is launched with `PERMISSION=plan` so it cannot execute mutating tools. Useful for review/analysis roles.
47
53
 
48
54
  ## Prompt Architecture
49
55
 
@@ -75,6 +81,6 @@ Built by `buildTemplateVars()` → `buildWorkerSystemPrompt()` + `resolveCluster
75
81
  ## DB Tables
76
82
 
77
83
  - `clusters` — cluster metadata (name, system_prompt, folders, enabled)
78
- - `cluster_roles` — role definitions scoped to a cluster (role_name, role, prompt, trigger_config, max_concurrency, cleanup_worker_dir, folders)
84
+ - `cluster_roles` — role definitions scoped to a cluster (role_name, role, prompt, trigger_config, max_concurrency, plan_mode, cleanup_worker_dir, folders)
79
85
 
80
86
  Workers are ephemeral containers, not database entities.
@@ -2,7 +2,9 @@
2
2
 
3
3
  ## Data Flow
4
4
 
5
- Chat agent's `coding_agent` tool → `runInteractiveContainer()` in `lib/tools/docker.js` → Docker container runs `coding-agent-claude-code` image (interactive runtime) with ttyd on port 7681 → browser navigates to `/code/{id}` → `TerminalView` (xterm.js) opens WebSocket → `ws-proxy.js` authenticates and proxies to container.
5
+ Chat agent's synthetic `coding_agent` tool wrapper → `runInteractiveContainer()` in `lib/tools/docker.js` → Docker container runs `coding-agent-{agent}` image (interactive runtime) ttyd on port 7681 fronts a tmux session that runs the agent CLI → browser navigates to `/code/{id}` → `TerminalView` (xterm.js) opens WebSocket → `ws-proxy.js` authenticates and proxies to container.
6
+
7
+ The interactive container's entrypoint launches a tmux session via the per-agent `docker/coding-agent/scripts/agents/<agent>/start-coding-session.sh` script. Multiple terminal tabs can attach to the same tmux session and survive WebSocket reconnects. Tab close + reopen reattaches; container restart re-creates the session with the prior agent session resumed (see "Session Continuity" below).
6
8
 
7
9
  ## WebSocket Auth
8
10
 
@@ -23,12 +25,18 @@ All actions use `requireAuth()` with ownership checks: `getCodeWorkspaces()`, `c
23
25
 
24
26
  ## Multi-Agent Backends
25
27
 
26
- Code workspaces support multiple coding agent backends. Agent selection is **global** via the `CODING_AGENT` config key (DB-backed, defaults to `claude-code`). There is no per-workspace agent selection all workspaces use the globally configured agent.
28
+ Code workspaces support multiple coding agent backends. Selection is **per-workspace** via the `codingAgent` column on `code_workspaces`, falling back to the global `CODING_AGENT` config key, then to `claude-code`. See `lib/code/actions.js:410`: `const agent = workspace.codingAgent || getConfig('CODING_AGENT') || 'claude-code';`. The same fallback chain is used by `lib/ai/index.js` for chat-mode streaming.
27
29
 
28
- **Supported agents**: `claude-code`, `pi`, `gemini-cli`, `codex-cli`, `opencode`. Each uses a different Docker image variant (`docker/coding-agent/Dockerfile.*`) and agent-specific setup/auth scripts in `docker/coding-agent/scripts/`.
30
+ **Supported agents**: `claude-code`, `pi-coding-agent`, `gemini-cli`, `codex-cli`, `opencode`, `kimi-cli`. Each uses a different Docker image variant (`docker/coding-agent/Dockerfile.*`) and agent-specific setup/auth scripts in `docker/coding-agent/scripts/`.
29
31
 
30
32
  **Configuration**: Users configure agents via `/admin/event-handler/coding-agents` — enable/disable agents, set per-agent auth mode (OAuth vs API key), provider, and model. `setCodingAgentDefault()` sets the global default. `buildAgentAuthEnv()` in `lib/tools/docker.js` resolves credentials from the settings DB at container launch time.
31
33
 
32
34
  **Container streaming**: `lib/containers/stream.js` provides an SSE endpoint (`/stream/containers`) that polls Docker for container stats every 3 seconds. Used by the Containers admin page for live monitoring.
33
35
 
34
36
  **Backend API in messages**: When an agent produces output, the `backendApi` field in message chunks identifies which agent backend generated the response.
37
+
38
+ ## Session Continuity
39
+
40
+ Code workspaces, chat-mode (SDK adapter), and headless agent jobs share session continuity through `lib/ai/session-manager.js`. Session IDs are written to per-port files inside the workspace volume — `~/.{agent}-ttyd-sessions/${PORT}` (or scope-prefixed when `SCOPE` is set). The agent CLI captures its session ID via per-agent hooks (see `docker/coding-agent/CLAUDE.md` § Session Tracking for the 5 patterns). On the next launch the entrypoint reads the saved ID and passes the agent's resume flag (`--continue`, `--resume`, `--session`, depending on agent).
41
+
42
+ Headless `run.sh` always reads from port `7681` — so SDK chat, manual chat tools, and code workspaces all converge on the same conversation when they share a workspace.
@@ -13,7 +13,11 @@ import {
13
13
  } from '../db/code-workspaces.js';
14
14
  import {
15
15
  getChatByWorkspaceId,
16
+ touchChatUpdatedAt,
16
17
  } from '../db/chats.js';
18
+ import { buildCodingAgentSystemPrompt } from '../ai/system-prompt.js';
19
+ import { resolveAgentScope } from '../ai/scope.js';
20
+ import { workspaceDir as getWorkspaceDir } from '../tools/docker.js';
17
21
  import {
18
22
  addSession,
19
23
  getSession as getTermSession,
@@ -136,7 +140,15 @@ export async function ensureCodeWorkspaceContainer(id) {
136
140
 
137
141
  // Inject agent job secrets when the linked chat is in agent mode
138
142
  const chat = getChatByWorkspaceId(id);
139
- const injectSecrets = chat?.chatMode === 'agent';
143
+ const isAgent = chat?.chatMode === 'agent';
144
+ const injectSecrets = isAgent;
145
+
146
+ // Resolve scope for system prompt skills
147
+ const wsBaseDir = getWorkspaceDir(id);
148
+ const repoDir = (await import('path')).join(wsBaseDir, 'workspace');
149
+ const wsScope = workspace.scope || null;
150
+ const { skillsDir } = resolveAgentScope(repoDir, wsScope);
151
+ const systemPrompt = buildCodingAgentSystemPrompt(isAgent ? 'agent' : 'code', isAgent ? skillsDir : null, isAgent ? wsScope : null);
140
152
 
141
153
  try {
142
154
  const { inspectContainer, startContainer, removeContainer, runInteractiveContainer } =
@@ -145,14 +157,17 @@ export async function ensureCodeWorkspaceContainer(id) {
145
157
  const info = await inspectContainer(workspace.containerName);
146
158
 
147
159
  if (!info) {
148
- // Container not found — recreate
160
+ // Container not found — recreate using the agent that was originally chosen at launch time
149
161
  await runInteractiveContainer({
150
162
  containerName: workspace.containerName,
163
+ codingAgent: workspace.codingAgent || undefined,
151
164
  repo: workspace.repo,
152
165
  branch: workspace.branch,
153
166
  featureBranch: workspace.featureBranch,
154
167
  workspaceId: id,
155
168
  injectSecrets,
169
+ systemPrompt,
170
+ scope: workspace.scope || undefined,
156
171
  });
157
172
  return { status: 'created' };
158
173
  }
@@ -172,15 +187,18 @@ export async function ensureCodeWorkspaceContainer(id) {
172
187
  }
173
188
  }
174
189
 
175
- // Dead, bad state, or start failed — remove and recreate
190
+ // Dead, bad state, or start failed — remove and recreate using the originally chosen agent
176
191
  await removeContainer(workspace.containerName);
177
192
  await runInteractiveContainer({
178
193
  containerName: workspace.containerName,
194
+ codingAgent: workspace.codingAgent || undefined,
179
195
  repo: workspace.repo,
180
196
  branch: workspace.branch,
181
197
  featureBranch: workspace.featureBranch,
182
198
  workspaceId: id,
183
199
  injectSecrets,
200
+ systemPrompt,
201
+ scope: workspace.scope || undefined,
184
202
  });
185
203
  return { status: 'created' };
186
204
  } catch (err) {
@@ -194,7 +212,7 @@ export async function ensureCodeWorkspaceContainer(id) {
194
212
  * @param {string} id - Workspace ID
195
213
  * @returns {Promise<{success: boolean, containerName?: string, message?: string}>}
196
214
  */
197
- export async function startInteractiveMode(id) {
215
+ export async function startInteractiveMode(id, agentOverride) {
198
216
  const user = await requireAuth();
199
217
  const workspace = getCodeWorkspaceById(id);
200
218
  if (!workspace || workspace.userId !== user.id) {
@@ -207,25 +225,38 @@ export async function startInteractiveMode(id) {
207
225
 
208
226
  // Inject agent job secrets when the linked chat is in agent mode
209
227
  const chat = getChatByWorkspaceId(id);
210
- const injectSecrets = chat?.chatMode === 'agent';
228
+ const isAgent = chat?.chatMode === 'agent';
229
+ const injectSecrets = isAgent;
230
+
231
+ // Resolve scope for system prompt skills
232
+ const wsBase = getWorkspaceDir(id);
233
+ const repoPath = (await import('path')).join(wsBase, 'workspace');
234
+ const wsScope2 = workspace.scope || null;
235
+ const { skillsDir: scopedSkillsDir } = resolveAgentScope(repoPath, wsScope2);
236
+ const systemPrompt = buildCodingAgentSystemPrompt(isAgent ? 'agent' : 'code', isAgent ? scopedSkillsDir : null, isAgent ? wsScope2 : null);
211
237
 
212
238
  try {
213
239
  const { getConfig } = await import('../config.js');
214
- const agent = getConfig('CODING_AGENT') || 'claude-code';
240
+ // Use the explicitly chosen agent, then fall back to the global config default
241
+ const agent = agentOverride || getConfig('CODING_AGENT') || 'claude-code';
215
242
  const shortId = id.replace(/-/g, '').slice(0, 8);
216
243
  const containerName = `${agent}-interactive-${shortId}`;
217
244
 
218
245
  const { runInteractiveContainer } = await import('../tools/docker.js');
219
246
  await runInteractiveContainer({
220
247
  containerName,
248
+ codingAgent: agent,
221
249
  repo: workspace.repo,
222
250
  branch: workspace.branch,
223
251
  featureBranch: workspace.featureBranch,
224
252
  workspaceId: id,
225
253
  injectSecrets,
254
+ systemPrompt,
255
+ scope: workspace.scope || undefined,
226
256
  });
227
257
 
228
- updateContainerName(id, containerName);
258
+ // Persist both the container name and the agent so recovery can use the same image
259
+ updateContainerName(id, containerName, agent);
229
260
  return { success: true, containerName };
230
261
  } catch (err) {
231
262
  console.error(`[startInteractiveMode] workspace=${id}`, err);
@@ -373,8 +404,10 @@ export async function createTerminalSession(id, type = 'shell') {
373
404
  const { randomUUID } = await import('crypto');
374
405
 
375
406
  // Start ttyd in the background, then find its PID via pgrep
407
+ // Use the agent the workspace was originally launched with — not the current global config,
408
+ // which may have changed since the container was started (causing wrong script path = session error)
376
409
  const { getConfig } = await import('../config.js');
377
- const agent = getConfig('CODING_AGENT') || 'claude-code';
410
+ const agent = workspace.codingAgent || getConfig('CODING_AGENT') || 'claude-code';
378
411
  const command = type === 'code'
379
412
  ? `nohup env PORT=${port} /scripts/common/start-ttyd-session.sh /scripts/agents/${agent}/start-coding-session.sh > /dev/null 2>&1 &`
380
413
  : `nohup env PORT=${port} /scripts/common/start-shell-session.sh > /dev/null 2>&1 &`;
@@ -593,6 +626,12 @@ export async function getWorkspaceDiffStats(id, authenticatedUser) {
593
626
 
594
627
  updateHasChanges(id, insertions > 0 || deletions > 0);
595
628
 
629
+ // Keep the linked chat's updatedAt fresh so it doesn't fall behind in the sidebar
630
+ if (insertions > 0 || deletions > 0) {
631
+ const chat = getChatByWorkspaceId(id);
632
+ if (chat) touchChatUpdatedAt(chat.id);
633
+ }
634
+
596
635
  // Sync featureBranch in DB if the actual branch differs
597
636
  if (currentBranch && currentBranch !== workspace.featureBranch) {
598
637
  const { updateFeatureBranch } = await import('../db/code-workspaces.js');
@@ -175,7 +175,7 @@ function TerminalView({ codeWorkspaceId, wsPath, isActive = true, showToolbar =
175
175
  fitAddonRef.current = fitAddon;
176
176
  term.open(containerRef.current);
177
177
  const style = document.createElement("style");
178
- style.textContent = `.xterm { padding: 5px; background-color: ${theme.background} !important; } .xterm-viewport { background-color: ${theme.background} !important; } .xterm-rows span { pointer-events: none; }`;
178
+ style.textContent = `.xterm { padding: 5px; background-color: ${theme.background} !important; } .xterm-viewport { background-color: ${theme.background} !important; touch-action: none; } .xterm-rows span { pointer-events: none; }`;
179
179
  containerRef.current.appendChild(style);
180
180
  styleRef.current = style;
181
181
  containerRef.current.style.backgroundColor = theme.background;
@@ -188,7 +188,7 @@ function TerminalView({ codeWorkspaceId, wsPath, isActive = true, showToolbar =
188
188
  toolbarRef.current.style.setProperty("--tb-dropup-bg", theme.background);
189
189
  }
190
190
  fitAddon.fit();
191
- const screenEl = containerRef.current.querySelector(".xterm-screen");
191
+ const termContainer = containerRef.current;
192
192
  let lastTouchY = null;
193
193
  let touchScrollAccum = 0;
194
194
  const LINE_HEIGHT = 15;
@@ -197,6 +197,7 @@ function TerminalView({ codeWorkspaceId, wsPath, isActive = true, showToolbar =
197
197
  lastTouchY = ev.touches[0].clientY;
198
198
  touchScrollAccum = 0;
199
199
  }
200
+ ev.stopPropagation();
200
201
  };
201
202
  const onTouchMove = (ev) => {
202
203
  if (lastTouchY === null || ev.touches.length !== 1) return;
@@ -210,16 +211,15 @@ function TerminalView({ codeWorkspaceId, wsPath, isActive = true, showToolbar =
210
211
  touchScrollAccum -= lines * LINE_HEIGHT;
211
212
  }
212
213
  ev.preventDefault();
214
+ ev.stopPropagation();
213
215
  };
214
216
  const onTouchEnd = () => {
215
217
  lastTouchY = null;
216
218
  touchScrollAccum = 0;
217
219
  };
218
- if (screenEl) {
219
- screenEl.addEventListener("touchstart", onTouchStart, { passive: true });
220
- screenEl.addEventListener("touchmove", onTouchMove, { passive: false });
221
- screenEl.addEventListener("touchend", onTouchEnd, { passive: true });
222
- }
220
+ termContainer.addEventListener("touchstart", onTouchStart, { passive: false, capture: true });
221
+ termContainer.addEventListener("touchmove", onTouchMove, { passive: false, capture: true });
222
+ termContainer.addEventListener("touchend", onTouchEnd, { passive: true, capture: true });
223
223
  term.onData((data) => {
224
224
  const ws = wsRef.current;
225
225
  if (ws && ws.readyState === WebSocket.OPEN) {
@@ -258,11 +258,9 @@ function TerminalView({ codeWorkspaceId, wsPath, isActive = true, showToolbar =
258
258
  clearTimeout(resizeTimeout);
259
259
  clearTimeout(retryTimer.current);
260
260
  window.removeEventListener("resize", handleResize);
261
- if (screenEl) {
262
- screenEl.removeEventListener("touchstart", onTouchStart);
263
- screenEl.removeEventListener("touchmove", onTouchMove);
264
- screenEl.removeEventListener("touchend", onTouchEnd);
265
- }
261
+ termContainer.removeEventListener("touchstart", onTouchStart, { capture: true });
262
+ termContainer.removeEventListener("touchmove", onTouchMove, { capture: true });
263
+ termContainer.removeEventListener("touchend", onTouchEnd, { capture: true });
266
264
  if (wsRef.current) wsRef.current.close();
267
265
  term.dispose();
268
266
  };
@@ -782,7 +780,7 @@ function TerminalView({ codeWorkspaceId, wsPath, isActive = true, showToolbar =
782
780
  ] }) })
783
781
  ] });
784
782
  }
785
- const STORAGE_KEY = "thepopebot-workspace-command";
783
+ const STORAGE_KEY = "thepopebot-workspace-command:code";
786
784
  function ToolbarCommandButton({ codeWorkspaceId, diffStats, onDiffStatsRefresh, onShowDiff }) {
787
785
  const [selectedCommand, setSelectedCommandState] = useState(() => {
788
786
  try {
@@ -798,6 +796,26 @@ function ToolbarCommandButton({ codeWorkspaceId, diffStats, onDiffStatsRefresh,
798
796
  } catch {
799
797
  }
800
798
  };
799
+ useEffect(() => {
800
+ let stored = null;
801
+ try {
802
+ stored = localStorage.getItem(STORAGE_KEY);
803
+ } catch {
804
+ }
805
+ if (stored) return;
806
+ let cancelled = false;
807
+ import("../chat/actions.js").then(({ getModeGitActionDefault }) => {
808
+ getModeGitActionDefault("code").then((val) => {
809
+ if (cancelled || !val) return;
810
+ setSelectedCommandState(val);
811
+ }).catch(() => {
812
+ });
813
+ }).catch(() => {
814
+ });
815
+ return () => {
816
+ cancelled = true;
817
+ };
818
+ }, []);
801
819
  const [commandRunning, setCommandRunning] = useState(false);
802
820
  const [dialogOpen, setDialogOpen] = useState(false);
803
821
  const [commandOutput, setCommandOutput] = useState("");
@@ -814,14 +832,6 @@ function ToolbarCommandButton({ codeWorkspaceId, diffStats, onDiffStatsRefresh,
814
832
  }, [dropupOpen]);
815
833
  const handleRun = useCallback(async () => {
816
834
  if (commandRunning) return;
817
- const fresh = await onDiffStatsRefresh?.();
818
- const stats = fresh || diffStats;
819
- if (!(stats?.insertions || 0) && !(stats?.deletions || 0)) {
820
- setDialogOpen(true);
821
- setCommandOutput("You have no changes.");
822
- setCommandExitCode(1);
823
- return;
824
- }
825
835
  setCommandRunning(true);
826
836
  setDialogOpen(true);
827
837
  setCommandOutput("");