@foxden-app/foxclaw 0.5.8 → 0.5.10

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.
package/dist/i18n.d.ts CHANGED
@@ -103,6 +103,7 @@ declare const MESSAGES: {
103
103
  readonly status_sync_on_turn_complete: "Sync on turn complete: {value}";
104
104
  readonly status_pending_approvals: "Pending approvals: {value}";
105
105
  readonly status_pending_user_inputs: "Pending questions: {value}";
106
+ readonly status_queued_turns: "Queued turns: {value}";
106
107
  readonly status_active_turns: "Active turns: {value}";
107
108
  readonly status_codex_account: "Codex account: {value}";
108
109
  readonly status_codex_plan: "Codex plan: {value}";
@@ -133,6 +134,7 @@ declare const MESSAGES: {
133
134
  readonly status_last_codex_update: "Last Codex update: {value}";
134
135
  readonly update_started: "FoxClaw update started. I will report here after installation, checks, and service restart complete.";
135
136
  readonly update_succeeded: "FoxClaw updated and restarted: {from} -> {to}.";
137
+ readonly update_changes_title: "What changed:";
136
138
  readonly update_codex_result: "Codex CLI: {from} -> {to}.";
137
139
  readonly update_failed: "FoxClaw update failed: {error}\nRun foxclaw update in a terminal for details.";
138
140
  readonly update_unavailable: "Self-update is unavailable in this runtime. Run foxclaw update in a terminal.";
@@ -254,8 +256,18 @@ declare const MESSAGES: {
254
256
  readonly watch_read_only_active: "This chat is watching that turn read-only. Use approval commands if needed, or /unwatch before sending a new prompt.";
255
257
  readonly watch_not_enabled: "This chat is not watching any thread.";
256
258
  readonly watch_stopped: "Stopped watching thread {threadId}.";
257
- readonly queued_prompt_set: "Queued. I will send it after the current turn finishes.";
259
+ readonly queued_prompt_set: "Queued #{position} ({id}). I will send it after the current turn finishes.";
258
260
  readonly queued_prompt_replaced: "Replaced the queued prompt. I will send the new one after the current turn finishes.";
261
+ readonly attachment_batch_staged: "Attachments staged: {count}. Send the prompt text to use them, or choose an action below.";
262
+ readonly attachment_batch_caption: "Caption: {value}";
263
+ readonly attachment_batch_consumed: "Attachments attached to the next Codex turn.";
264
+ readonly attachment_batch_queued: "Attachments queued as turn #{position} ({id}). I will send it after the current turn finishes.";
265
+ readonly attachment_batch_cleared: "Attachment batch cleared.";
266
+ readonly attachment_batch_missing: "This attachment batch is no longer active.";
267
+ readonly attachment_batch_mismatch: "This attachment batch does not match this message.";
268
+ readonly attachment_batch_default_prompt: "Analyze these attachments.";
269
+ readonly attachment_batch_button_analyze: "Analyze";
270
+ readonly attachment_batch_button_clear: "Clear";
259
271
  readonly bound_to_thread: "Bound to thread {threadId}";
260
272
  readonly line_title: "Title: {value}";
261
273
  readonly line_cwd: "CWD: {value}";
@@ -744,6 +756,7 @@ declare const MESSAGES: {
744
756
  readonly status_sync_on_turn_complete: "回复完成后同步:{value}";
745
757
  readonly status_pending_approvals: "待审批:{value}";
746
758
  readonly status_pending_user_inputs: "待确认问题:{value}";
759
+ readonly status_queued_turns: "排队中的回复:{value}";
747
760
  readonly status_active_turns: "进行中的回复:{value}";
748
761
  readonly status_codex_account: "Codex 账号:{value}";
749
762
  readonly status_codex_plan: "Codex 套餐:{value}";
@@ -774,6 +787,7 @@ declare const MESSAGES: {
774
787
  readonly status_last_codex_update: "最近 Codex 升级:{value}";
775
788
  readonly update_started: "已开始升级 FoxClaw。安装、自检和服务重启完成后,我会在这里回报结果。";
776
789
  readonly update_succeeded: "FoxClaw 已升级并重启:{from} -> {to}。";
790
+ readonly update_changes_title: "更新内容:";
777
791
  readonly update_codex_result: "Codex CLI:{from} -> {to}。";
778
792
  readonly update_failed: "FoxClaw 升级失败:{error}\n请在终端运行 foxclaw update 查看详情。";
779
793
  readonly update_unavailable: "当前运行方式不支持自升级,请在终端运行 foxclaw update。";
@@ -895,8 +909,18 @@ declare const MESSAGES: {
895
909
  readonly watch_read_only_active: "当前聊天正在只读观察这轮。需要审批时可用审批命令;要发送新 prompt 请先 /unwatch。";
896
910
  readonly watch_not_enabled: "当前没有正在观察的线程。";
897
911
  readonly watch_stopped: "已停止观察线程 {threadId}。";
898
- readonly queued_prompt_set: "已排队。当前回复结束后我会自动发送。";
912
+ readonly queued_prompt_set: "已排队 #{position}({id})。当前回复结束后我会自动发送。";
899
913
  readonly queued_prompt_replaced: "已替换排队消息。当前回复结束后我会自动发送新的内容。";
914
+ readonly attachment_batch_staged: "附件已暂存:{count} 个。发送下一条文字即可带上这些附件,或使用下面的按钮。";
915
+ readonly attachment_batch_caption: "说明:{value}";
916
+ readonly attachment_batch_consumed: "附件已带入下一轮 Codex。";
917
+ readonly attachment_batch_queued: "附件已作为第 #{position} 个回复排队({id})。当前回复结束后我会自动发送。";
918
+ readonly attachment_batch_cleared: "已清除这组附件。";
919
+ readonly attachment_batch_missing: "这组附件已经不在待处理状态。";
920
+ readonly attachment_batch_mismatch: "这个附件按钮不属于当前消息。";
921
+ readonly attachment_batch_default_prompt: "分析这些附件。";
922
+ readonly attachment_batch_button_analyze: "分析";
923
+ readonly attachment_batch_button_clear: "清除";
900
924
  readonly bound_to_thread: "已绑定到线程 {threadId}";
901
925
  readonly line_title: "标题:{value}";
902
926
  readonly line_cwd: "工作目录:{value}";
package/dist/i18n.js CHANGED
@@ -101,6 +101,7 @@ const MESSAGES = {
101
101
  status_sync_on_turn_complete: 'Sync on turn complete: {value}',
102
102
  status_pending_approvals: 'Pending approvals: {value}',
103
103
  status_pending_user_inputs: 'Pending questions: {value}',
104
+ status_queued_turns: 'Queued turns: {value}',
104
105
  status_active_turns: 'Active turns: {value}',
105
106
  status_codex_account: 'Codex account: {value}',
106
107
  status_codex_plan: 'Codex plan: {value}',
@@ -131,6 +132,7 @@ const MESSAGES = {
131
132
  status_last_codex_update: 'Last Codex update: {value}',
132
133
  update_started: 'FoxClaw update started. I will report here after installation, checks, and service restart complete.',
133
134
  update_succeeded: 'FoxClaw updated and restarted: {from} -> {to}.',
135
+ update_changes_title: 'What changed:',
134
136
  update_codex_result: 'Codex CLI: {from} -> {to}.',
135
137
  update_failed: 'FoxClaw update failed: {error}\nRun foxclaw update in a terminal for details.',
136
138
  update_unavailable: 'Self-update is unavailable in this runtime. Run foxclaw update in a terminal.',
@@ -252,8 +254,18 @@ const MESSAGES = {
252
254
  watch_read_only_active: 'This chat is watching that turn read-only. Use approval commands if needed, or /unwatch before sending a new prompt.',
253
255
  watch_not_enabled: 'This chat is not watching any thread.',
254
256
  watch_stopped: 'Stopped watching thread {threadId}.',
255
- queued_prompt_set: 'Queued. I will send it after the current turn finishes.',
257
+ queued_prompt_set: 'Queued #{position} ({id}). I will send it after the current turn finishes.',
256
258
  queued_prompt_replaced: 'Replaced the queued prompt. I will send the new one after the current turn finishes.',
259
+ attachment_batch_staged: 'Attachments staged: {count}. Send the prompt text to use them, or choose an action below.',
260
+ attachment_batch_caption: 'Caption: {value}',
261
+ attachment_batch_consumed: 'Attachments attached to the next Codex turn.',
262
+ attachment_batch_queued: 'Attachments queued as turn #{position} ({id}). I will send it after the current turn finishes.',
263
+ attachment_batch_cleared: 'Attachment batch cleared.',
264
+ attachment_batch_missing: 'This attachment batch is no longer active.',
265
+ attachment_batch_mismatch: 'This attachment batch does not match this message.',
266
+ attachment_batch_default_prompt: 'Analyze these attachments.',
267
+ attachment_batch_button_analyze: 'Analyze',
268
+ attachment_batch_button_clear: 'Clear',
257
269
  bound_to_thread: 'Bound to thread {threadId}',
258
270
  line_title: 'Title: {value}',
259
271
  line_cwd: 'CWD: {value}',
@@ -742,6 +754,7 @@ const MESSAGES = {
742
754
  status_sync_on_turn_complete: '回复完成后同步:{value}',
743
755
  status_pending_approvals: '待审批:{value}',
744
756
  status_pending_user_inputs: '待确认问题:{value}',
757
+ status_queued_turns: '排队中的回复:{value}',
745
758
  status_active_turns: '进行中的回复:{value}',
746
759
  status_codex_account: 'Codex 账号:{value}',
747
760
  status_codex_plan: 'Codex 套餐:{value}',
@@ -772,6 +785,7 @@ const MESSAGES = {
772
785
  status_last_codex_update: '最近 Codex 升级:{value}',
773
786
  update_started: '已开始升级 FoxClaw。安装、自检和服务重启完成后,我会在这里回报结果。',
774
787
  update_succeeded: 'FoxClaw 已升级并重启:{from} -> {to}。',
788
+ update_changes_title: '更新内容:',
775
789
  update_codex_result: 'Codex CLI:{from} -> {to}。',
776
790
  update_failed: 'FoxClaw 升级失败:{error}\n请在终端运行 foxclaw update 查看详情。',
777
791
  update_unavailable: '当前运行方式不支持自升级,请在终端运行 foxclaw update。',
@@ -893,8 +907,18 @@ const MESSAGES = {
893
907
  watch_read_only_active: '当前聊天正在只读观察这轮。需要审批时可用审批命令;要发送新 prompt 请先 /unwatch。',
894
908
  watch_not_enabled: '当前没有正在观察的线程。',
895
909
  watch_stopped: '已停止观察线程 {threadId}。',
896
- queued_prompt_set: '已排队。当前回复结束后我会自动发送。',
910
+ queued_prompt_set: '已排队 #{position}({id})。当前回复结束后我会自动发送。',
897
911
  queued_prompt_replaced: '已替换排队消息。当前回复结束后我会自动发送新的内容。',
912
+ attachment_batch_staged: '附件已暂存:{count} 个。发送下一条文字即可带上这些附件,或使用下面的按钮。',
913
+ attachment_batch_caption: '说明:{value}',
914
+ attachment_batch_consumed: '附件已带入下一轮 Codex。',
915
+ attachment_batch_queued: '附件已作为第 #{position} 个回复排队({id})。当前回复结束后我会自动发送。',
916
+ attachment_batch_cleared: '已清除这组附件。',
917
+ attachment_batch_missing: '这组附件已经不在待处理状态。',
918
+ attachment_batch_mismatch: '这个附件按钮不属于当前消息。',
919
+ attachment_batch_default_prompt: '分析这些附件。',
920
+ attachment_batch_button_analyze: '分析',
921
+ attachment_batch_button_clear: '清除',
898
922
  bound_to_thread: '已绑定到线程 {threadId}',
899
923
  line_title: '标题:{value}',
900
924
  line_cwd: '工作目录:{value}',
package/dist/main.js CHANGED
@@ -289,6 +289,7 @@ async function runServeCli() {
289
289
  currentBindings: store.countBindings(),
290
290
  pendingApprovals: store.countPendingApprovals(),
291
291
  pendingUserInputs: store.countPendingUserInputs(),
292
+ queuedTurns: store.countQueuedTurnInputs(),
292
293
  activeTurns: statuses.reduce((sum, status) => sum + status.activeTurns, 0)
293
294
  + (weixinStatus?.activeTurns ?? 0),
294
295
  lastError: statuses.find((status) => status.lastError)?.lastError
@@ -585,6 +586,7 @@ async function runServeCli() {
585
586
  currentBindings: 0,
586
587
  pendingApprovals: 0,
587
588
  pendingUserInputs: 0,
589
+ queuedTurns: 0,
588
590
  activeTurns: 0,
589
591
  lastError: null,
590
592
  updatedAt: new Date().toISOString(),
@@ -1132,6 +1134,7 @@ function runDoctorChecks() {
1132
1134
  warnIfProxyEnvMissingFromLoadedEnv();
1133
1135
  warnIfProxyConfigNeedsAttention();
1134
1136
  warnIfInstalledServiceNodeLooksWrong();
1137
+ warnIfSystemdUserLingerDisabled();
1135
1138
  return passed;
1136
1139
  }
1137
1140
  function warnIfProxyConfigNeedsAttention() {
@@ -1204,6 +1207,21 @@ function warnIfInstalledServiceNodeLooksWrong() {
1204
1207
  console.log(`[WARN] installed service node is older than 24: ${nodePath}${version ? ` (${version})` : ''}`);
1205
1208
  console.log('[WARN] Run foxclaw start from a Node 24 shell to refresh the service unit.');
1206
1209
  }
1210
+ function warnIfSystemdUserLingerDisabled() {
1211
+ if (process.platform !== 'linux' || !hasCommand('loginctl')) {
1212
+ return;
1213
+ }
1214
+ const user = currentServiceUser();
1215
+ const linger = readSystemdUserLinger(user);
1216
+ if (linger === 'enabled') {
1217
+ console.log(`[OK] systemd user linger enabled: ${user}`);
1218
+ return;
1219
+ }
1220
+ if (linger === 'disabled') {
1221
+ console.log(`[WARN] systemd user linger is disabled for ${user}; user services may stop after logout.`);
1222
+ console.log('[WARN] Run foxclaw start to let FoxClaw enable it, or run: sudo loginctl enable-linger "$USER"');
1223
+ }
1224
+ }
1207
1225
  function extractNodePathFromExecStart(execStart) {
1208
1226
  const tokens = execStart.split(/\s+/).map(systemdUnescape).filter(Boolean);
1209
1227
  const directNode = tokens[0] || '';
@@ -1262,6 +1280,7 @@ function installSystemd() {
1262
1280
  console.log(`[OK] updated FoxClaw ExecStart override: ${update.path}`);
1263
1281
  }
1264
1282
  }
1283
+ ensureSystemdUserLingerEnabled();
1265
1284
  spawnChecked('systemctl', ['--user', 'daemon-reload']);
1266
1285
  spawnChecked('systemctl', ['--user', 'enable', unitName]);
1267
1286
  const restarted = spawnSync('systemctl', ['--user', 'restart', unitName], { stdio: 'inherit' });
@@ -1272,6 +1291,32 @@ function installSystemd() {
1272
1291
  console.log(`Status: systemctl --user status ${unitName}`);
1273
1292
  console.log(`Logs: journalctl --user -u ${unitName} -f`);
1274
1293
  }
1294
+ function ensureSystemdUserLingerEnabled() {
1295
+ if (process.platform !== 'linux') {
1296
+ return;
1297
+ }
1298
+ if (!hasCommand('loginctl')) {
1299
+ console.log('[WARN] loginctl not found; cannot enable systemd user linger automatically.');
1300
+ return;
1301
+ }
1302
+ const user = currentServiceUser();
1303
+ const before = readSystemdUserLinger(user);
1304
+ if (before === 'enabled') {
1305
+ console.log(`[OK] systemd user linger enabled: ${user}`);
1306
+ return;
1307
+ }
1308
+ console.log(`[INFO] Enabling systemd user linger for ${user} so FoxClaw keeps running after logout.`);
1309
+ let result = spawnSync('loginctl', ['enable-linger', user], { stdio: 'inherit' });
1310
+ if (result.status !== 0 && hasCommand('sudo')) {
1311
+ result = spawnSync('sudo', ['-n', 'loginctl', 'enable-linger', user], { stdio: 'inherit' });
1312
+ }
1313
+ if (result.status === 0 && readSystemdUserLinger(user) === 'enabled') {
1314
+ console.log(`[OK] systemd user linger enabled: ${user}`);
1315
+ return;
1316
+ }
1317
+ console.log(`[WARN] Could not enable systemd user linger automatically for ${user}.`);
1318
+ console.log(`[WARN] FoxClaw is installed, but it may stop after logout until you run: sudo loginctl enable-linger ${shellQuote(user)}`);
1319
+ }
1275
1320
  function uninstallSystemd() {
1276
1321
  if (!hasCommand('systemctl')) {
1277
1322
  console.error('systemctl not found');
@@ -1392,6 +1437,36 @@ function buildServicePath(nodeDir) {
1392
1437
  function serviceEnvPath() {
1393
1438
  return path.resolve(process.env.FOXCLAW_ENV?.trim() || getLoadedEnvPath() || DEFAULT_ENV_PATH);
1394
1439
  }
1440
+ function currentServiceUser() {
1441
+ return process.env.USER?.trim()
1442
+ || process.env.LOGNAME?.trim()
1443
+ || os.userInfo().username;
1444
+ }
1445
+ function readSystemdUserLinger(user) {
1446
+ const valueResult = spawnSync('loginctl', ['show-user', user, '-p', 'Linger', '--value'], { encoding: 'utf8' });
1447
+ if (valueResult.status === 0) {
1448
+ return parseSystemdUserLinger(valueResult.stdout);
1449
+ }
1450
+ const propertyResult = spawnSync('loginctl', ['show-user', user, '-p', 'Linger'], { encoding: 'utf8' });
1451
+ if (propertyResult.status === 0) {
1452
+ return parseSystemdUserLinger(propertyResult.stdout);
1453
+ }
1454
+ return 'unknown';
1455
+ }
1456
+ function parseSystemdUserLinger(output) {
1457
+ const value = output.trim().replace(/^Linger=/, '').toLowerCase();
1458
+ if (value === 'yes')
1459
+ return 'enabled';
1460
+ if (value === 'no')
1461
+ return 'disabled';
1462
+ return 'unknown';
1463
+ }
1464
+ function shellQuote(value) {
1465
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value)) {
1466
+ return value;
1467
+ }
1468
+ return `'${value.replace(/'/g, `'\\''`)}'`;
1469
+ }
1395
1470
  function hasStandardNodeProxyEnv() {
1396
1471
  return STANDARD_NODE_PROXY_ENV_KEYS.some((key) => Boolean(proxyEnvValue(key)));
1397
1472
  }
@@ -1,4 +1,4 @@
1
- import type { AccessPresetValue, ActiveTurnMessageMode, AppLocale, CachedThread, ChatSessionSettings, CollaborationModeValue, PendingApprovalRecord, ReasoningEffortValue, ThreadBinding } from '../types.js';
1
+ import type { AccessPresetValue, ActiveTurnMessageMode, AppLocale, CachedThread, ChatSessionSettings, CollaborationModeValue, GuidedPlanSessionRecord, GuidedPlanSessionState, PendingAttachmentBatchRecord, PendingAttachmentBatchStatus, PendingApprovalRecord, QueuedTurnInputRecord, QueuedTurnInputStatus, ReasoningEffortValue, ThreadBinding } from '../types.js';
2
2
  export interface ActiveTurnPreviewRecord {
3
3
  turnId: string;
4
4
  scopeId: string;
@@ -84,10 +84,34 @@ export declare class BridgeStore {
84
84
  markPendingUserInputInterrupted(localId: string): void;
85
85
  listPendingUserInputs(): PendingUserInputStoredRecord[];
86
86
  countPendingUserInputs(): number;
87
+ saveQueuedTurnInput(record: QueuedTurnInputRecord): void;
88
+ getQueuedTurnInput(queueId: string): QueuedTurnInputRecord | null;
89
+ peekQueuedTurnInput(scopeId: string): QueuedTurnInputRecord | null;
90
+ listQueuedTurnInputs(scopeId?: string): QueuedTurnInputRecord[];
91
+ countQueuedTurnInputs(scopeId?: string): number;
92
+ updateQueuedTurnInputStatus(queueId: string, status: QueuedTurnInputStatus, error?: string | null): void;
93
+ cancelQueuedTurnInputs(scopeId: string): number;
94
+ requeueInterruptedQueuedTurnInputs(): number;
95
+ savePendingAttachmentBatch(record: PendingAttachmentBatchRecord): void;
96
+ getPendingAttachmentBatch(batchId: string): PendingAttachmentBatchRecord | null;
97
+ findPendingAttachmentBatchByMediaGroup(scopeId: string, mediaGroupId: string): PendingAttachmentBatchRecord | null;
98
+ getLatestPendingAttachmentBatch(scopeId: string): PendingAttachmentBatchRecord | null;
99
+ updatePendingAttachmentBatchMessage(batchId: string, messageId: number): void;
100
+ resolvePendingAttachmentBatch(batchId: string, status: PendingAttachmentBatchStatus): void;
101
+ clearPendingAttachmentBatches(scopeId: string): number;
102
+ saveGuidedPlanSession(record: GuidedPlanSessionRecord): void;
103
+ getGuidedPlanSession(sessionId: string): GuidedPlanSessionRecord | null;
104
+ findOpenGuidedPlanSession(scopeId: string, turnId?: string): GuidedPlanSessionRecord | null;
105
+ listOpenGuidedPlanSessions(): GuidedPlanSessionRecord[];
106
+ updateGuidedPlanSessionMessage(sessionId: string, messageId: number): void;
107
+ updateGuidedPlanSessionState(sessionId: string, state: GuidedPlanSessionState): void;
87
108
  insertAudit(direction: 'inbound' | 'outbound', chatId: string, eventType: string, summary: string): void;
88
109
  close(): void;
89
110
  private mapApproval;
90
111
  private mapPendingUserInput;
112
+ private mapQueuedTurnInput;
113
+ private mapPendingAttachmentBatch;
114
+ private mapGuidedPlanSession;
91
115
  private writeChatSettings;
92
116
  getWeixinContextToken(scopeId: string): string | null;
93
117
  setWeixinContextToken(scopeId: string, contextToken: string): void;
@@ -98,6 +98,61 @@ export class BridgeStore {
98
98
  created_at INTEGER NOT NULL,
99
99
  PRIMARY KEY (input_local_id, question_index, message_kind)
100
100
  );
101
+ CREATE TABLE IF NOT EXISTS queued_turn_inputs (
102
+ queue_id TEXT PRIMARY KEY,
103
+ scope_id TEXT NOT NULL,
104
+ chat_id TEXT NOT NULL,
105
+ chat_type TEXT NOT NULL,
106
+ topic_id INTEGER,
107
+ thread_id TEXT NOT NULL,
108
+ input_json TEXT NOT NULL,
109
+ source_summary TEXT NOT NULL,
110
+ message_id INTEGER,
111
+ status TEXT NOT NULL,
112
+ error TEXT,
113
+ created_at INTEGER NOT NULL,
114
+ updated_at INTEGER NOT NULL,
115
+ resolved_at INTEGER
116
+ );
117
+ CREATE INDEX IF NOT EXISTS queued_turn_inputs_scope_status_idx
118
+ ON queued_turn_inputs(scope_id, status, created_at);
119
+ CREATE TABLE IF NOT EXISTS pending_attachment_batches (
120
+ batch_id TEXT PRIMARY KEY,
121
+ scope_id TEXT NOT NULL,
122
+ chat_id TEXT NOT NULL,
123
+ chat_type TEXT NOT NULL,
124
+ topic_id INTEGER,
125
+ thread_id TEXT NOT NULL,
126
+ cwd TEXT,
127
+ media_group_id TEXT,
128
+ attachments_json TEXT NOT NULL,
129
+ caption TEXT NOT NULL,
130
+ message_id INTEGER,
131
+ status TEXT NOT NULL,
132
+ created_at INTEGER NOT NULL,
133
+ updated_at INTEGER NOT NULL,
134
+ resolved_at INTEGER
135
+ );
136
+ CREATE INDEX IF NOT EXISTS pending_attachment_batches_scope_status_idx
137
+ ON pending_attachment_batches(scope_id, status, updated_at);
138
+ CREATE TABLE IF NOT EXISTS guided_plan_sessions (
139
+ session_id TEXT PRIMARY KEY,
140
+ scope_id TEXT NOT NULL,
141
+ chat_id TEXT NOT NULL,
142
+ chat_type TEXT NOT NULL,
143
+ topic_id INTEGER,
144
+ thread_id TEXT NOT NULL,
145
+ turn_id TEXT NOT NULL,
146
+ cwd TEXT,
147
+ plan_markdown TEXT NOT NULL,
148
+ message_id INTEGER,
149
+ state TEXT NOT NULL,
150
+ created_at INTEGER NOT NULL,
151
+ updated_at INTEGER NOT NULL,
152
+ resolved_at INTEGER
153
+ );
154
+ CREATE INDEX IF NOT EXISTS guided_plan_sessions_scope_state_idx
155
+ ON guided_plan_sessions(scope_id, state, updated_at);
101
156
  CREATE TABLE IF NOT EXISTS audit_logs (
102
157
  id INTEGER PRIMARY KEY AUTOINCREMENT,
103
158
  direction TEXT NOT NULL,
@@ -438,6 +493,204 @@ export class BridgeStore {
438
493
  const row = this.db.prepare('SELECT COUNT(*) AS count FROM pending_user_inputs WHERE resolved_at IS NULL').get();
439
494
  return Number(row?.count ?? 0);
440
495
  }
496
+ saveQueuedTurnInput(record) {
497
+ this.db.prepare(`
498
+ INSERT INTO queued_turn_inputs (
499
+ queue_id, scope_id, chat_id, chat_type, topic_id, thread_id, input_json, source_summary,
500
+ message_id, status, error, created_at, updated_at, resolved_at
501
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
502
+ ON CONFLICT(queue_id) DO UPDATE SET
503
+ scope_id = excluded.scope_id,
504
+ chat_id = excluded.chat_id,
505
+ chat_type = excluded.chat_type,
506
+ topic_id = excluded.topic_id,
507
+ thread_id = excluded.thread_id,
508
+ input_json = excluded.input_json,
509
+ source_summary = excluded.source_summary,
510
+ message_id = excluded.message_id,
511
+ status = excluded.status,
512
+ error = excluded.error,
513
+ created_at = excluded.created_at,
514
+ updated_at = excluded.updated_at,
515
+ resolved_at = excluded.resolved_at
516
+ `).run(record.queueId, record.scopeId, record.chatId, record.chatType, record.topicId, record.threadId, record.inputJson, record.sourceSummary, record.messageId, record.status, record.error, record.createdAt, record.updatedAt, record.resolvedAt);
517
+ }
518
+ getQueuedTurnInput(queueId) {
519
+ const row = this.db.prepare('SELECT * FROM queued_turn_inputs WHERE queue_id = ?').get(queueId);
520
+ return row ? this.mapQueuedTurnInput(row) : null;
521
+ }
522
+ peekQueuedTurnInput(scopeId) {
523
+ const row = this.db.prepare(`
524
+ SELECT * FROM queued_turn_inputs
525
+ WHERE scope_id = ? AND status = 'queued'
526
+ ORDER BY created_at ASC
527
+ LIMIT 1
528
+ `).get(scopeId);
529
+ return row ? this.mapQueuedTurnInput(row) : null;
530
+ }
531
+ listQueuedTurnInputs(scopeId) {
532
+ const sql = scopeId
533
+ ? `SELECT * FROM queued_turn_inputs WHERE scope_id = ? AND status IN ('queued', 'processing') ORDER BY created_at ASC`
534
+ : `SELECT * FROM queued_turn_inputs WHERE status IN ('queued', 'processing') ORDER BY created_at ASC`;
535
+ const rows = scopeId
536
+ ? this.db.prepare(sql).all(scopeId)
537
+ : this.db.prepare(sql).all();
538
+ return rows.map((row) => this.mapQueuedTurnInput(row));
539
+ }
540
+ countQueuedTurnInputs(scopeId) {
541
+ const row = scopeId
542
+ ? this.db.prepare(`SELECT COUNT(*) AS count FROM queued_turn_inputs WHERE scope_id = ? AND status IN ('queued', 'processing')`).get(scopeId)
543
+ : this.db.prepare(`SELECT COUNT(*) AS count FROM queued_turn_inputs WHERE status IN ('queued', 'processing')`).get();
544
+ return Number(row?.count ?? 0);
545
+ }
546
+ updateQueuedTurnInputStatus(queueId, status, error = null) {
547
+ const resolvedAt = status === 'queued' || status === 'processing' ? null : Date.now();
548
+ this.db.prepare(`
549
+ UPDATE queued_turn_inputs
550
+ SET status = ?, error = ?, updated_at = ?, resolved_at = ?
551
+ WHERE queue_id = ?
552
+ `).run(status, error, Date.now(), resolvedAt, queueId);
553
+ }
554
+ cancelQueuedTurnInputs(scopeId) {
555
+ const result = this.db.prepare(`
556
+ UPDATE queued_turn_inputs
557
+ SET status = 'cancelled', updated_at = ?, resolved_at = ?
558
+ WHERE scope_id = ? AND status = 'queued'
559
+ `).run(Date.now(), Date.now(), scopeId);
560
+ return Number(result.changes ?? 0);
561
+ }
562
+ requeueInterruptedQueuedTurnInputs() {
563
+ const result = this.db.prepare(`
564
+ UPDATE queued_turn_inputs
565
+ SET status = 'queued', error = NULL, updated_at = ?, resolved_at = NULL
566
+ WHERE status = 'processing'
567
+ `).run(Date.now());
568
+ return Number(result.changes ?? 0);
569
+ }
570
+ savePendingAttachmentBatch(record) {
571
+ this.db.prepare(`
572
+ INSERT INTO pending_attachment_batches (
573
+ batch_id, scope_id, chat_id, chat_type, topic_id, thread_id, cwd, media_group_id,
574
+ attachments_json, caption, message_id, status, created_at, updated_at, resolved_at
575
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
576
+ ON CONFLICT(batch_id) DO UPDATE SET
577
+ scope_id = excluded.scope_id,
578
+ chat_id = excluded.chat_id,
579
+ chat_type = excluded.chat_type,
580
+ topic_id = excluded.topic_id,
581
+ thread_id = excluded.thread_id,
582
+ cwd = excluded.cwd,
583
+ media_group_id = excluded.media_group_id,
584
+ attachments_json = excluded.attachments_json,
585
+ caption = excluded.caption,
586
+ message_id = excluded.message_id,
587
+ status = excluded.status,
588
+ created_at = excluded.created_at,
589
+ updated_at = excluded.updated_at,
590
+ resolved_at = excluded.resolved_at
591
+ `).run(record.batchId, record.scopeId, record.chatId, record.chatType, record.topicId, record.threadId, record.cwd, record.mediaGroupId, record.attachmentsJson, record.caption, record.messageId, record.status, record.createdAt, record.updatedAt, record.resolvedAt);
592
+ }
593
+ getPendingAttachmentBatch(batchId) {
594
+ const row = this.db.prepare('SELECT * FROM pending_attachment_batches WHERE batch_id = ?').get(batchId);
595
+ return row ? this.mapPendingAttachmentBatch(row) : null;
596
+ }
597
+ findPendingAttachmentBatchByMediaGroup(scopeId, mediaGroupId) {
598
+ const row = this.db.prepare(`
599
+ SELECT * FROM pending_attachment_batches
600
+ WHERE scope_id = ? AND media_group_id = ? AND status = 'pending'
601
+ ORDER BY updated_at DESC
602
+ LIMIT 1
603
+ `).get(scopeId, mediaGroupId);
604
+ return row ? this.mapPendingAttachmentBatch(row) : null;
605
+ }
606
+ getLatestPendingAttachmentBatch(scopeId) {
607
+ const row = this.db.prepare(`
608
+ SELECT * FROM pending_attachment_batches
609
+ WHERE scope_id = ? AND status = 'pending'
610
+ ORDER BY updated_at DESC
611
+ LIMIT 1
612
+ `).get(scopeId);
613
+ return row ? this.mapPendingAttachmentBatch(row) : null;
614
+ }
615
+ updatePendingAttachmentBatchMessage(batchId, messageId) {
616
+ this.db.prepare('UPDATE pending_attachment_batches SET message_id = ?, updated_at = ? WHERE batch_id = ?').run(messageId, Date.now(), batchId);
617
+ }
618
+ resolvePendingAttachmentBatch(batchId, status) {
619
+ this.db.prepare(`
620
+ UPDATE pending_attachment_batches
621
+ SET status = ?, updated_at = ?, resolved_at = ?
622
+ WHERE batch_id = ?
623
+ `).run(status, Date.now(), Date.now(), batchId);
624
+ }
625
+ clearPendingAttachmentBatches(scopeId) {
626
+ const result = this.db.prepare(`
627
+ UPDATE pending_attachment_batches
628
+ SET status = 'cleared', updated_at = ?, resolved_at = ?
629
+ WHERE scope_id = ? AND status = 'pending'
630
+ `).run(Date.now(), Date.now(), scopeId);
631
+ return Number(result.changes ?? 0);
632
+ }
633
+ saveGuidedPlanSession(record) {
634
+ this.db.prepare(`
635
+ INSERT INTO guided_plan_sessions (
636
+ session_id, scope_id, chat_id, chat_type, topic_id, thread_id, turn_id, cwd,
637
+ plan_markdown, message_id, state, created_at, updated_at, resolved_at
638
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
639
+ ON CONFLICT(session_id) DO UPDATE SET
640
+ scope_id = excluded.scope_id,
641
+ chat_id = excluded.chat_id,
642
+ chat_type = excluded.chat_type,
643
+ topic_id = excluded.topic_id,
644
+ thread_id = excluded.thread_id,
645
+ turn_id = excluded.turn_id,
646
+ cwd = excluded.cwd,
647
+ plan_markdown = excluded.plan_markdown,
648
+ message_id = excluded.message_id,
649
+ state = excluded.state,
650
+ created_at = excluded.created_at,
651
+ updated_at = excluded.updated_at,
652
+ resolved_at = excluded.resolved_at
653
+ `).run(record.sessionId, record.scopeId, record.chatId, record.chatType, record.topicId, record.threadId, record.turnId, record.cwd, record.planMarkdown, record.messageId, record.state, record.createdAt, record.updatedAt, record.resolvedAt);
654
+ }
655
+ getGuidedPlanSession(sessionId) {
656
+ const row = this.db.prepare('SELECT * FROM guided_plan_sessions WHERE session_id = ?').get(sessionId);
657
+ return row ? this.mapGuidedPlanSession(row) : null;
658
+ }
659
+ findOpenGuidedPlanSession(scopeId, turnId) {
660
+ const row = turnId
661
+ ? this.db.prepare(`
662
+ SELECT * FROM guided_plan_sessions
663
+ WHERE scope_id = ? AND turn_id = ? AND state = 'awaiting_confirmation'
664
+ ORDER BY updated_at DESC
665
+ LIMIT 1
666
+ `).get(scopeId, turnId)
667
+ : this.db.prepare(`
668
+ SELECT * FROM guided_plan_sessions
669
+ WHERE scope_id = ? AND state = 'awaiting_confirmation'
670
+ ORDER BY updated_at DESC
671
+ LIMIT 1
672
+ `).get(scopeId);
673
+ return row ? this.mapGuidedPlanSession(row) : null;
674
+ }
675
+ listOpenGuidedPlanSessions() {
676
+ const rows = this.db.prepare(`
677
+ SELECT * FROM guided_plan_sessions
678
+ WHERE state = 'awaiting_confirmation'
679
+ ORDER BY updated_at ASC
680
+ `).all();
681
+ return rows.map((row) => this.mapGuidedPlanSession(row));
682
+ }
683
+ updateGuidedPlanSessionMessage(sessionId, messageId) {
684
+ this.db.prepare('UPDATE guided_plan_sessions SET message_id = ?, updated_at = ? WHERE session_id = ?').run(messageId, Date.now(), sessionId);
685
+ }
686
+ updateGuidedPlanSessionState(sessionId, state) {
687
+ const resolvedAt = state === 'awaiting_confirmation' || state === 'executing' ? null : Date.now();
688
+ this.db.prepare(`
689
+ UPDATE guided_plan_sessions
690
+ SET state = ?, updated_at = ?, resolved_at = ?
691
+ WHERE session_id = ?
692
+ `).run(state, Date.now(), resolvedAt, sessionId);
693
+ }
441
694
  insertAudit(direction, chatId, eventType, summary) {
442
695
  this.db.prepare('INSERT INTO audit_logs (direction, chat_id, event_type, summary, created_at) VALUES (?, ?, ?, ?, ?)').run(direction, chatId, eventType, summary, Date.now());
443
696
  }
@@ -482,6 +735,61 @@ export class BridgeStore {
482
735
  resolvedAt: row.resolved_at === null ? null : Number(row.resolved_at),
483
736
  };
484
737
  }
738
+ mapQueuedTurnInput(row) {
739
+ return {
740
+ queueId: String(row.queue_id),
741
+ scopeId: String(row.scope_id),
742
+ chatId: String(row.chat_id),
743
+ chatType: String(row.chat_type),
744
+ topicId: row.topic_id === null ? null : Number(row.topic_id),
745
+ threadId: String(row.thread_id),
746
+ inputJson: String(row.input_json),
747
+ sourceSummary: String(row.source_summary),
748
+ messageId: row.message_id === null ? null : Number(row.message_id),
749
+ status: normalizeQueuedTurnInputStatus(row.status),
750
+ error: row.error === null ? null : String(row.error),
751
+ createdAt: Number(row.created_at),
752
+ updatedAt: Number(row.updated_at),
753
+ resolvedAt: row.resolved_at === null ? null : Number(row.resolved_at),
754
+ };
755
+ }
756
+ mapPendingAttachmentBatch(row) {
757
+ return {
758
+ batchId: String(row.batch_id),
759
+ scopeId: String(row.scope_id),
760
+ chatId: String(row.chat_id),
761
+ chatType: String(row.chat_type),
762
+ topicId: row.topic_id === null ? null : Number(row.topic_id),
763
+ threadId: String(row.thread_id),
764
+ cwd: row.cwd === null ? null : String(row.cwd),
765
+ mediaGroupId: row.media_group_id === null ? null : String(row.media_group_id),
766
+ attachmentsJson: String(row.attachments_json),
767
+ caption: String(row.caption ?? ''),
768
+ messageId: row.message_id === null ? null : Number(row.message_id),
769
+ status: normalizePendingAttachmentBatchStatus(row.status),
770
+ createdAt: Number(row.created_at),
771
+ updatedAt: Number(row.updated_at),
772
+ resolvedAt: row.resolved_at === null ? null : Number(row.resolved_at),
773
+ };
774
+ }
775
+ mapGuidedPlanSession(row) {
776
+ return {
777
+ sessionId: String(row.session_id),
778
+ scopeId: String(row.scope_id),
779
+ chatId: String(row.chat_id),
780
+ chatType: String(row.chat_type),
781
+ topicId: row.topic_id === null ? null : Number(row.topic_id),
782
+ threadId: String(row.thread_id),
783
+ turnId: String(row.turn_id),
784
+ cwd: row.cwd === null ? null : String(row.cwd),
785
+ planMarkdown: String(row.plan_markdown),
786
+ messageId: row.message_id === null ? null : Number(row.message_id),
787
+ state: normalizeGuidedPlanSessionState(row.state),
788
+ createdAt: Number(row.created_at),
789
+ updatedAt: Number(row.updated_at),
790
+ resolvedAt: row.resolved_at === null ? null : Number(row.resolved_at),
791
+ };
792
+ }
485
793
  writeChatSettings(chatId, model, reasoningEffort, locale, accessPreset, collaborationMode, serviceTier, activeTurnMessageMode) {
486
794
  this.db.prepare(`
487
795
  INSERT INTO chat_settings (chat_id, model, reasoning_effort, locale, access_preset, collaboration_mode, service_tier, active_turn_message_mode, updated_at)
@@ -615,3 +923,16 @@ function normalizeCollaborationMode(value) {
615
923
  function normalizeActiveTurnMessageMode(value) {
616
924
  return value === 'steer' || value === 'queue' ? value : null;
617
925
  }
926
+ function normalizeQueuedTurnInputStatus(value) {
927
+ return value === 'processing' || value === 'completed' || value === 'cancelled' || value === 'failed'
928
+ ? value
929
+ : 'queued';
930
+ }
931
+ function normalizePendingAttachmentBatchStatus(value) {
932
+ return value === 'consumed' || value === 'cleared' ? value : 'pending';
933
+ }
934
+ function normalizeGuidedPlanSessionState(value) {
935
+ return value === 'executing' || value === 'cancelled' || value === 'completed'
936
+ ? value
937
+ : 'awaiting_confirmation';
938
+ }
@@ -12,6 +12,7 @@ export interface TelegramTextEvent {
12
12
  userId: string;
13
13
  text: string;
14
14
  messageId: number;
15
+ mediaGroupId?: string | null;
15
16
  attachments: TelegramInboundAttachment[];
16
17
  entities: TelegramMessageEntity[];
17
18
  replyToBot: boolean;
@@ -245,6 +245,7 @@ export class TelegramGateway extends EventEmitter {
245
245
  userId: String(update.message.from.id),
246
246
  text,
247
247
  messageId: update.message.message_id,
248
+ mediaGroupId: update.message.media_group_id ?? null,
248
249
  attachments,
249
250
  entities,
250
251
  replyToBot,