@loicngr/kobo 1.11.12 → 1.11.14
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/CHANGELOG.md +8 -0
- package/dist/mcp-server/kobo-tasks-handlers.js +26 -3
- package/dist/server/index.js +5 -7
- package/dist/server/routes/export.js +2 -1
- package/dist/server/routes/workspaces.js +15 -0
- package/dist/server/services/agent/engines/claude-code/engine.js +81 -6
- package/dist/server/services/agent/engines/claude-code/event-mapper.js +9 -5
- package/dist/server/services/agent/engines/codex/engine.js +54 -2
- package/dist/server/services/agent/orchestrator.js +34 -8
- package/dist/server/services/cron-service.js +2 -1
- package/dist/server/services/dev-server-service.js +13 -1
- package/dist/server/services/wakeup-service.js +14 -12
- package/dist/server/utils/git-ops.js +3 -2
- package/dist/server/utils/safe-path.js +34 -0
- package/package.json +3 -3
- package/src/client/dist/pwa/assets/{ActivityFeed-Ds8cAMNc.js → ActivityFeed-DqDJDX2v.js} +1 -1
- package/src/client/dist/pwa/assets/{ChangelogPage-DO6DNO4Q.js → ChangelogPage-Dw2LbIK2.js} +1 -1
- package/src/client/dist/pwa/assets/{ClosePopup-DuAqn8Pb.js → ClosePopup-eKuUr7CY.js} +1 -1
- package/src/client/dist/pwa/assets/{CreatePage-BnMSo_79.js → CreatePage--4KDhiYt.js} +1 -1
- package/src/client/dist/pwa/assets/{DiffViewer-CMnFGReI.js → DiffViewer-Bl9D1N1M.js} +3 -3
- package/src/client/dist/pwa/assets/{HealthPage-BRAxQKSP.js → HealthPage-CaOdjuVx.js} +1 -1
- package/src/client/dist/pwa/assets/{MainLayout-BN1ug95K.js → MainLayout-DP9shoO1.js} +2 -2
- package/src/client/dist/pwa/assets/{QBtnToggle-DsCDNQzX.js → QBtnToggle-BwPZ0Smh.js} +1 -1
- package/src/client/dist/pwa/assets/{QChip-DWGDTZnX.js → QChip-CY0_8v_e.js} +1 -1
- package/src/client/dist/pwa/assets/{QSelect-8Ip46vhk.js → QSelect-xHphd7hW.js} +1 -1
- package/src/client/dist/pwa/assets/{QTooltip-Bz4g3bmr.js → QTooltip-DyS20jDT.js} +1 -1
- package/src/client/dist/pwa/assets/{SearchPage-BD53VJEr.js → SearchPage-CggXs3dH.js} +1 -1
- package/src/client/dist/pwa/assets/{SettingsPage-3rYdJZga.js → SettingsPage-8AWL_4DM.js} +1 -1
- package/src/client/dist/pwa/assets/{TouchPan-BKu8V4eN.js → TouchPan-Cd9KTb6-.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspacePage-CbC_t5Rx.js → WorkspacePage-CJKSrSEC.js} +3 -3
- package/src/client/dist/pwa/assets/{cssMode-vaMVESGy.js → cssMode-BsN8TWME.js} +1 -1
- package/src/client/dist/pwa/assets/{documents-CyKQ9lT6.js → documents-DJy1tgPd.js} +1 -1
- package/src/client/dist/pwa/assets/{editor.api-CH3dhpKq.js → editor.api-Dj6TIm_z.js} +1 -1
- package/src/client/dist/pwa/assets/{expand-template-DQQQftsk.js → expand-template-By-8Db7_.js} +1 -1
- package/src/client/dist/pwa/assets/{freemarker2-deLpERsj.js → freemarker2-DC0xjWx5.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-DdjS05Tc.js → handlebars-0Uz9nw5d.js} +1 -1
- package/src/client/dist/pwa/assets/{html-pK1ZEFvP.js → html-BYPeU1Vb.js} +1 -1
- package/src/client/dist/pwa/assets/{htmlMode-DJFDI9Ru.js → htmlMode-B0UND_vz.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-DPDR4mQI.js +1 -0
- package/src/client/dist/pwa/assets/{index-B_OmZ65F.js → index-CtK8ORpZ.js} +3 -3
- package/src/client/dist/pwa/assets/{javascript-DbuFfxco.js → javascript-B8PZW8pe.js} +1 -1
- package/src/client/dist/pwa/assets/{jsonMode-B0Fu8Dcc.js → jsonMode-BixnmFse.js} +1 -1
- package/src/client/dist/pwa/assets/{kobo-commands-rb43ZeWG.js → kobo-commands-CX8pforo.js} +1 -1
- package/src/client/dist/pwa/assets/{layout-tAFrvQ5V.js → layout-BAE6NR2A.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-DqDIKOCy.js → liquid-BH16_Zn1.js} +1 -1
- package/src/client/dist/pwa/assets/{lspLanguageFeatures-CVcEibfJ.js → lspLanguageFeatures-YMzTIuK0.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-Bs4CovhI.js → mdx-CrrWJ7fg.js} +1 -1
- package/src/client/dist/pwa/assets/{network-auth-DYrrh7Zr.js → network-auth-NMnCVoaZ.js} +1 -1
- package/src/client/dist/pwa/assets/{pending-diff-open-D1IYH8Pd.js → pending-diff-open-DHlXLorX.js} +1 -1
- package/src/client/dist/pwa/assets/{project-color-DO6uj29M.js → project-color-BJrCEQ0A.js} +1 -1
- package/src/client/dist/pwa/assets/{python-BJoP0uN-.js → python-fk3K3s3o.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-cwD-YBcr.js → razor-Dg1yHOal.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-DZnIueUV.js → render-chat-markdown-C00A6CfY.js} +1 -1
- package/src/client/dist/pwa/assets/{tsMode-of3oS9sU.js → tsMode-qJ7nTArc.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-BTWMoUxn.js → typescript-DayfKox1.js} +1 -1
- package/src/client/dist/pwa/assets/{use-onboarding-BTFkRBjE.js → use-onboarding-CF_D2gbm.js} +1 -1
- package/src/client/dist/pwa/assets/{use-quasar-C1RCDSb6.js → use-quasar-C3oo6nVn.js} +1 -1
- package/src/client/dist/pwa/assets/{vs-DDeaNKsH.js → vs-qnWnWXG9.js} +3 -3
- package/src/client/dist/pwa/assets/{workers-DQnKuGcq.js → workers-CvvtZekC.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-RglGWD_3.js → xml-CJ1QFxhD.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-CSqGQpai.js → yaml-C3N49N5E.js} +1 -1
- package/src/client/dist/pwa/index.html +1 -1
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/mcp-server/kobo-tasks-handlers.ts +24 -4
- package/src/client/dist/pwa/assets/i18n-CK38o-IF.js +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ All notable changes to Kōbō are documented here. The format is based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/). Each release is an `## <version>`
|
|
5
5
|
section — the in-app "What's new" dialog reads this file.
|
|
6
6
|
|
|
7
|
+
## 1.11.14
|
|
8
|
+
|
|
9
|
+
- ⬆️ develop - refactor(deps): Bump SDKs + refactor shutdown + hardening sécurité
|
|
10
|
+
|
|
11
|
+
## 1.11.13
|
|
12
|
+
|
|
13
|
+
- fix(engines): unstick agent sessions that never emit session:ended
|
|
14
|
+
|
|
7
15
|
## 1.11.12
|
|
8
16
|
|
|
9
17
|
- ✨ develop - feat(engines): Garder les sessions ouvertes pour les subagents en arrière-plan
|
|
@@ -4,6 +4,7 @@ import { nanoid } from 'nanoid';
|
|
|
4
4
|
import * as cronService from '../server/services/cron-service.js';
|
|
5
5
|
import * as settingsService from '../server/services/settings-service.js';
|
|
6
6
|
import { slugifyProjectName } from '../server/utils/project-slug.js';
|
|
7
|
+
import { resolveExistingPathInside } from '../server/utils/safe-path.js';
|
|
7
8
|
import { resolveWorkspaceWorktreePath } from '../server/utils/worktree-paths.js';
|
|
8
9
|
/** Allowed task status values. */
|
|
9
10
|
export const VALID_TASK_STATUSES = ['pending', 'in_progress', 'done'];
|
|
@@ -296,6 +297,7 @@ export function getTicketSourcesHandler(worktreePath) {
|
|
|
296
297
|
export const DOCUMENT_DIRS = ['docs/plans', 'docs/superpowers', '.ai/thoughts'];
|
|
297
298
|
/** Depth cap to keep recursion bounded even on pathological symlink loops. */
|
|
298
299
|
const DOC_MAX_DEPTH = 8;
|
|
300
|
+
const DOC_MAX_FILE_BYTES = 1024 * 1024;
|
|
299
301
|
function walkMarkdownFiles(rootAbs, rootRel, out, depth = 0) {
|
|
300
302
|
if (depth > DOC_MAX_DEPTH)
|
|
301
303
|
return;
|
|
@@ -313,11 +315,13 @@ function walkMarkdownFiles(rootAbs, rootRel, out, depth = 0) {
|
|
|
313
315
|
const relEntry = `${rootRel}/${entry}`;
|
|
314
316
|
let stat;
|
|
315
317
|
try {
|
|
316
|
-
stat = fs.
|
|
318
|
+
stat = fs.lstatSync(absEntry);
|
|
317
319
|
}
|
|
318
320
|
catch {
|
|
319
321
|
continue;
|
|
320
322
|
}
|
|
323
|
+
if (stat.isSymbolicLink())
|
|
324
|
+
continue;
|
|
321
325
|
if (stat.isDirectory()) {
|
|
322
326
|
walkMarkdownFiles(absEntry, relEntry, out, depth + 1);
|
|
323
327
|
}
|
|
@@ -336,7 +340,14 @@ export function listDocumentsHandler(worktreePath) {
|
|
|
336
340
|
const absDir = path.join(worktreePath, dir);
|
|
337
341
|
if (!fs.existsSync(absDir))
|
|
338
342
|
continue;
|
|
339
|
-
|
|
343
|
+
try {
|
|
344
|
+
if (fs.lstatSync(absDir).isSymbolicLink())
|
|
345
|
+
continue;
|
|
346
|
+
walkMarkdownFiles(resolveExistingPathInside(worktreePath, absDir), dir, documents);
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
// A document directory outside the worktree is never eligible.
|
|
350
|
+
}
|
|
340
351
|
}
|
|
341
352
|
documents.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime());
|
|
342
353
|
return documents;
|
|
@@ -361,7 +372,19 @@ export function readDocumentHandler(worktreePath, relPath) {
|
|
|
361
372
|
if (!fs.existsSync(abs)) {
|
|
362
373
|
throw new Error(`Document not found: ${normalized}`);
|
|
363
374
|
}
|
|
364
|
-
|
|
375
|
+
const lstat = fs.lstatSync(abs);
|
|
376
|
+
if (lstat.isSymbolicLink()) {
|
|
377
|
+
// Resolve for a consistent error if the link escapes; internal links are
|
|
378
|
+
// rejected too so list/read share the same no-symlink contract.
|
|
379
|
+
resolveExistingPathInside(worktreePath, abs);
|
|
380
|
+
throw new Error('Symbolic links are not allowed in documents');
|
|
381
|
+
}
|
|
382
|
+
const safeAbs = resolveExistingPathInside(worktreePath, abs);
|
|
383
|
+
const stat = fs.statSync(safeAbs);
|
|
384
|
+
if (stat.size > DOC_MAX_FILE_BYTES) {
|
|
385
|
+
throw new Error(`Document too large (max ${DOC_MAX_FILE_BYTES / 1024 / 1024} MB)`);
|
|
386
|
+
}
|
|
387
|
+
return { path: normalized, content: fs.readFileSync(safeAbs, 'utf-8') };
|
|
365
388
|
}
|
|
366
389
|
/**
|
|
367
390
|
* Append a thought / decision / note to
|
package/dist/server/index.js
CHANGED
|
@@ -25,12 +25,12 @@ import templatesRouter from './routes/templates.js';
|
|
|
25
25
|
import usageRoutes from './routes/usage.js';
|
|
26
26
|
import voiceRouter from './routes/voice.js';
|
|
27
27
|
import workspacesRouter from './routes/workspaces.js';
|
|
28
|
-
import { getAvailableSkills, reconcileOrphanSessions, restoreRetryCountsFromDb, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopWatchdog, } from './services/agent/orchestrator.js';
|
|
28
|
+
import { getAvailableSkills, reconcileOrphanSessions, restoreRetryCountsFromDb, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopAllAgents, stopWatchdog, } from './services/agent/orchestrator.js';
|
|
29
29
|
import * as autoLoopService from './services/auto-loop-service.js';
|
|
30
30
|
import { runContentMigrationIfNeeded } from './services/content-migration-service.js';
|
|
31
31
|
import * as cronService from './services/cron-service.js';
|
|
32
32
|
import { createDailyDbBackupIfNeeded, createPreMigrationBackup } from './services/db-backup-service.js';
|
|
33
|
-
import { startDevServer, stopDevServer } from './services/dev-server-service.js';
|
|
33
|
+
import { startDevServer, stopAllDevServers, stopDevServer } from './services/dev-server-service.js';
|
|
34
34
|
import { authorizeWsUpgrade, generateToken, getLanUrls, resolveBindHost, resolveNetworkAccessEnvOverrides, } from './services/network-access-service.js';
|
|
35
35
|
import { startPrWatcher, stopPrWatcher } from './services/pr-watcher-service.js';
|
|
36
36
|
import * as quotaBackoffService from './services/quota-backoff-service.js';
|
|
@@ -42,7 +42,6 @@ import * as wakeupService from './services/wakeup-service.js';
|
|
|
42
42
|
import { emit, emitEphemeral, handleConnection, setMessageHandler } from './services/websocket-service.js';
|
|
43
43
|
import { getActiveSession, getWorkspace, updateWorkspaceStatus } from './services/workspace-service.js';
|
|
44
44
|
import { getClientSpaPath, getDbPath, getKoboHome, getPackageVersion } from './utils/paths.js';
|
|
45
|
-
import { initProcessCleanup, killAll as killAllTrackedProcesses } from './utils/process-tracker.js';
|
|
46
45
|
console.log(`[kobo] Kōbō home: ${getKoboHome()}`);
|
|
47
46
|
// Initialize DB + run migrations
|
|
48
47
|
const db = getDb();
|
|
@@ -72,7 +71,6 @@ void createDailyDbBackupIfNeeded(db, getDbPath()).then((r) => {
|
|
|
72
71
|
}
|
|
73
72
|
});
|
|
74
73
|
// Initialize process cleanup, agent watchdog, PR watcher, and wakeup rehydration
|
|
75
|
-
initProcessCleanup();
|
|
76
74
|
reconcileOrphanSessions();
|
|
77
75
|
startWatchdog();
|
|
78
76
|
wakeupService.rehydrate();
|
|
@@ -490,10 +488,10 @@ async function gracefulShutdown(signal) {
|
|
|
490
488
|
catch {
|
|
491
489
|
// Best-effort
|
|
492
490
|
}
|
|
493
|
-
//
|
|
491
|
+
// Stop managed agents and both direct/Docker dev servers before closing DB.
|
|
494
492
|
try {
|
|
495
|
-
|
|
496
|
-
console.log('[kobo]
|
|
493
|
+
await Promise.all([stopAllAgents(), stopAllDevServers()]);
|
|
494
|
+
console.log('[kobo] Agents and dev servers stopped');
|
|
497
495
|
}
|
|
498
496
|
catch {
|
|
499
497
|
// Best-effort
|
|
@@ -5,7 +5,8 @@ import * as workspaceService from '../services/workspace-service.js';
|
|
|
5
5
|
const app = new Hono();
|
|
6
6
|
/** Quote a CSV cell when it contains a delimiter, quote, or newline (RFC 4180). */
|
|
7
7
|
function csvCell(value) {
|
|
8
|
-
|
|
8
|
+
const safeValue = /^[\s]*[=+\-@]/.test(value) ? `'${value}` : value;
|
|
9
|
+
return /[",\r\n]/.test(safeValue) ? `"${safeValue.replace(/"/g, '""')}"` : safeValue;
|
|
9
10
|
}
|
|
10
11
|
function csvRow(values) {
|
|
11
12
|
return values.map(csvCell).join(',');
|
|
@@ -42,6 +42,7 @@ import * as worktreeService from '../services/worktree-service.js';
|
|
|
42
42
|
import { resolveUniqueBranchAndPath } from '../utils/branch-resolver.js';
|
|
43
43
|
import * as gitOps from '../utils/git-ops.js';
|
|
44
44
|
import { slugifyProjectName } from '../utils/project-slug.js';
|
|
45
|
+
import * as safePath from '../utils/safe-path.js';
|
|
45
46
|
import { resolveSiblingWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
46
47
|
/** Hono sub-router for workspace CRUD, tasks, agent lifecycle, git operations, and PR creation. */
|
|
47
48
|
const app = new Hono();
|
|
@@ -2692,6 +2693,13 @@ app.get('/:id/diff-file', (c) => {
|
|
|
2692
2693
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
2693
2694
|
}
|
|
2694
2695
|
const worktreePath = workspace.worktreePath;
|
|
2696
|
+
try {
|
|
2697
|
+
safePath.assertPathInside(worktreePath, filePath);
|
|
2698
|
+
}
|
|
2699
|
+
catch (err) {
|
|
2700
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2701
|
+
return c.json({ error: message }, 400);
|
|
2702
|
+
}
|
|
2695
2703
|
if (c.req.query('mode') === 'commits') {
|
|
2696
2704
|
const from = c.req.query('from');
|
|
2697
2705
|
const to = c.req.query('to');
|
|
@@ -2750,6 +2758,13 @@ app.post('/:id/rollback-file', async (c) => {
|
|
|
2750
2758
|
if (!filePath) {
|
|
2751
2759
|
return c.json({ error: 'Missing or invalid `path` field' }, 400);
|
|
2752
2760
|
}
|
|
2761
|
+
try {
|
|
2762
|
+
safePath.assertPathInside(workspace.worktreePath, filePath);
|
|
2763
|
+
}
|
|
2764
|
+
catch (err) {
|
|
2765
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2766
|
+
return c.json({ error: message }, 400);
|
|
2767
|
+
}
|
|
2753
2768
|
let target;
|
|
2754
2769
|
try {
|
|
2755
2770
|
target = gitOps.rollbackFile(workspace.worktreePath, workspace.workingBranch, filePath);
|
|
@@ -20,6 +20,12 @@ const RESULT_DRAIN_TIMEOUT_MS = 15_000;
|
|
|
20
20
|
// without producing a result message. Keep a separate, conservative guard for
|
|
21
21
|
// that shape; active tool/message traffic always cancels and re-arms it.
|
|
22
22
|
const TEXT_IDLE_TIMEOUT_MS = 120_000;
|
|
23
|
+
// Safety net for a subagent whose terminal `task_notification` carries a
|
|
24
|
+
// status outside the mapper's known-terminal set (SDK schema drift) — it
|
|
25
|
+
// never clears `activeSubagentToolCallIds`, which otherwise blocks
|
|
26
|
+
// `inputStream.close()`/the result-drain watchdog forever. Generous window:
|
|
27
|
+
// legitimate subagents can run for several minutes.
|
|
28
|
+
const SUBAGENT_STALL_TIMEOUT_MS = 10 * 60_000;
|
|
23
29
|
const MAX_PENDING_USER_MESSAGES = 20;
|
|
24
30
|
function toMcpServersMap(specs) {
|
|
25
31
|
if (!specs || specs.length === 0)
|
|
@@ -91,7 +97,9 @@ export function createClaudeCodeEngine() {
|
|
|
91
97
|
async start(options, onEvent) {
|
|
92
98
|
const abortController = new AbortController();
|
|
93
99
|
const mapperState = createMapperState();
|
|
94
|
-
|
|
100
|
+
// UI/tool ids can differ from the SDK task id. Key by the stable UI id
|
|
101
|
+
// so a terminal notification that omits task_id still clears the task.
|
|
102
|
+
const activeSubagentTaskIds = new Map();
|
|
95
103
|
// Pending canUseTool callbacks, keyed by SDK ctx.toolUseID.
|
|
96
104
|
const pendingResolvers = new Map();
|
|
97
105
|
const isInteractive = options.agentPermissionMode === 'interactive';
|
|
@@ -278,6 +286,41 @@ export function createClaudeCodeEngine() {
|
|
|
278
286
|
}, RESULT_DRAIN_TIMEOUT_MS);
|
|
279
287
|
resultDrainTimer.unref?.();
|
|
280
288
|
};
|
|
289
|
+
// Safety net for the "waiting on a background subagent" branch below:
|
|
290
|
+
// if `activeSubagentToolCallIds` never empties (a missed/unrecognised
|
|
291
|
+
// terminal notification), the workspace would otherwise stay `executing`
|
|
292
|
+
// forever. Force the drain through once this fires.
|
|
293
|
+
let subagentStallTimer;
|
|
294
|
+
const clearSubagentStallWatchdog = () => {
|
|
295
|
+
if (!subagentStallTimer)
|
|
296
|
+
return;
|
|
297
|
+
clearTimeout(subagentStallTimer);
|
|
298
|
+
subagentStallTimer = undefined;
|
|
299
|
+
};
|
|
300
|
+
// Re-armed (not merely armed-once) on every qualifying `result` so the
|
|
301
|
+
// deadline tracks the last observed activity, matching the warning
|
|
302
|
+
// message below — a long chain of legitimate subagent-backed turns
|
|
303
|
+
// must not be cut off just because 10 minutes have passed in total.
|
|
304
|
+
const armSubagentStallWatchdog = () => {
|
|
305
|
+
clearSubagentStallWatchdog();
|
|
306
|
+
subagentStallTimer = setTimeout(() => {
|
|
307
|
+
subagentStallTimer = undefined;
|
|
308
|
+
// A message queued between arming and firing means a new turn is
|
|
309
|
+
// about to start on this same stream — don't discard the subagent
|
|
310
|
+
// tracking or force-close under it; the next 'result' re-evaluates.
|
|
311
|
+
if (inputStream.hasUnansweredInput(completedResponses))
|
|
312
|
+
return;
|
|
313
|
+
console.warn(`[claude-engine] Subagent(s) still tracked active ${SUBAGENT_STALL_TIMEOUT_MS}ms after the last turn ended — forcing session drain.`);
|
|
314
|
+
activeSubagentTaskIds.clear();
|
|
315
|
+
// This is a forced, unclean termination (the subagent may still be
|
|
316
|
+
// alive server-side) — never report it as a normal completion, so
|
|
317
|
+
// auto-loop doesn't treat an orphaned run as forward progress.
|
|
318
|
+
mapperState.sawErrorResult = true;
|
|
319
|
+
inputStream.close();
|
|
320
|
+
armResultDrainWatchdog();
|
|
321
|
+
}, SUBAGENT_STALL_TIMEOUT_MS);
|
|
322
|
+
subagentStallTimer.unref?.();
|
|
323
|
+
};
|
|
281
324
|
const iteratorPromise = (async () => {
|
|
282
325
|
iteratorRunning = true;
|
|
283
326
|
try {
|
|
@@ -287,9 +330,20 @@ export function createClaudeCodeEngine() {
|
|
|
287
330
|
if (ev.kind !== 'subagent:progress')
|
|
288
331
|
continue;
|
|
289
332
|
if (ev.status === 'running')
|
|
290
|
-
|
|
333
|
+
activeSubagentTaskIds.set(ev.toolCallId, ev.taskId ?? ev.toolCallId);
|
|
291
334
|
else
|
|
292
|
-
|
|
335
|
+
activeSubagentTaskIds.delete(ev.toolCallId);
|
|
336
|
+
}
|
|
337
|
+
// The stall watchdog is only armed while we're waiting purely on
|
|
338
|
+
// background subagents (no further SDK turn expected unless one
|
|
339
|
+
// reports done). React the moment the last one clears instead of
|
|
340
|
+
// waiting for a 'result' message that may never come.
|
|
341
|
+
if (subagentStallTimer &&
|
|
342
|
+
activeSubagentTaskIds.size === 0 &&
|
|
343
|
+
!inputStream.hasUnansweredInput(completedResponses)) {
|
|
344
|
+
clearSubagentStallWatchdog();
|
|
345
|
+
inputStream.close();
|
|
346
|
+
armResultDrainWatchdog();
|
|
293
347
|
}
|
|
294
348
|
for (const ev of events) {
|
|
295
349
|
if (ev.kind === 'session:started')
|
|
@@ -307,9 +361,15 @@ export function createClaudeCodeEngine() {
|
|
|
307
361
|
clearTextIdleWatchdog();
|
|
308
362
|
completedResponses++;
|
|
309
363
|
// A queued forced message starts the next response on this same SDK stream.
|
|
310
|
-
if (!inputStream.hasUnansweredInput(completedResponses)
|
|
311
|
-
|
|
312
|
-
|
|
364
|
+
if (!inputStream.hasUnansweredInput(completedResponses)) {
|
|
365
|
+
if (activeSubagentTaskIds.size === 0) {
|
|
366
|
+
clearSubagentStallWatchdog();
|
|
367
|
+
inputStream.close();
|
|
368
|
+
armResultDrainWatchdog();
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
armSubagentStallWatchdog();
|
|
372
|
+
}
|
|
313
373
|
}
|
|
314
374
|
}
|
|
315
375
|
}
|
|
@@ -354,6 +414,7 @@ export function createClaudeCodeEngine() {
|
|
|
354
414
|
resultDrainTimer = undefined;
|
|
355
415
|
}
|
|
356
416
|
clearTextIdleWatchdog();
|
|
417
|
+
clearSubagentStallWatchdog();
|
|
357
418
|
// Drain any callback still pending (SDK terminated while awaiting an
|
|
358
419
|
// answer). canUseTool's abort path covers signalled stops; this
|
|
359
420
|
// covers natural iterator completion.
|
|
@@ -391,6 +452,20 @@ export function createClaudeCodeEngine() {
|
|
|
391
452
|
// subtype `error_during_execution` through the normal iterator —
|
|
392
453
|
// the mapper needs this flag to treat it as a clean stop.
|
|
393
454
|
mapperState.userInterrupted = true;
|
|
455
|
+
// The soft interrupt below only ends the foreground turn — a
|
|
456
|
+
// background subagent task keeps running unless told to stop via
|
|
457
|
+
// its own API, which would otherwise leave the Stop button
|
|
458
|
+
// appearing to do nothing for up to SUBAGENT_STALL_TIMEOUT_MS.
|
|
459
|
+
for (const taskId of activeSubagentTaskIds.values()) {
|
|
460
|
+
try {
|
|
461
|
+
void q.stopTask(taskId).catch(() => {
|
|
462
|
+
/* best-effort */
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
/* best-effort */
|
|
467
|
+
}
|
|
468
|
+
}
|
|
394
469
|
const qq = q;
|
|
395
470
|
if (typeof qq.interrupt === 'function') {
|
|
396
471
|
try {
|
|
@@ -192,16 +192,20 @@ export function mapSdkMessage(msg, state) {
|
|
|
192
192
|
return events;
|
|
193
193
|
}
|
|
194
194
|
if (subtype === 'task_started' || subtype === 'task_progress' || subtype === 'task_notification') {
|
|
195
|
-
const
|
|
195
|
+
const taskId = typeof parsed.task_id === 'string' ? parsed.task_id : undefined;
|
|
196
|
+
const toolCallId = typeof parsed.tool_use_id === 'string' ? parsed.tool_use_id : taskId;
|
|
196
197
|
if (toolCallId) {
|
|
197
198
|
const usage = parsed.usage;
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
199
|
+
// Unlike task_started/task_progress (in-flight signals), task_notification
|
|
200
|
+
// is only ever emitted once a task settles — its `status` value is a
|
|
201
|
+
// reason, not a liveness signal. Treat every task_notification as
|
|
202
|
+
// terminal instead of matching against a status whitelist, so an SDK
|
|
203
|
+
// status value added later doesn't leave the subagent stuck 'running'.
|
|
204
|
+
const isDone = subtype === 'task_notification';
|
|
202
205
|
events.push({
|
|
203
206
|
kind: 'subagent:progress',
|
|
204
207
|
toolCallId,
|
|
208
|
+
...(taskId ? { taskId } : {}),
|
|
205
209
|
status: isDone ? 'done' : 'running',
|
|
206
210
|
description: typeof parsed.description === 'string' ? parsed.description : undefined,
|
|
207
211
|
taskType: typeof parsed.task_type === 'string' ? parsed.task_type : undefined,
|
|
@@ -11,6 +11,13 @@ import { createTurnLiveness } from './turn-liveness.js';
|
|
|
11
11
|
/** Long enough for normal tool work, short enough to recover a lost turn event. */
|
|
12
12
|
export const CODEX_TURN_IDLE_TIMEOUT_MS = 120_000;
|
|
13
13
|
export const CODEX_GRACEFUL_INTERRUPT_TIMEOUT_MS = 3_000;
|
|
14
|
+
// Safety net while `turnLiveness` is paused for background subagents: that
|
|
15
|
+
// pause is deliberately unbounded (a legitimate subagent can run long), but
|
|
16
|
+
// if a thread never reports a terminal status (dropped notification, or the
|
|
17
|
+
// sub-thread's own process hanging), nothing else would ever resume it or
|
|
18
|
+
// resolve `turnDonePromise` — the session would hang forever. Generous
|
|
19
|
+
// window, refreshed on every observed subagent-thread status change.
|
|
20
|
+
export const CODEX_SUBAGENT_STALL_TIMEOUT_MS = 10 * 60_000;
|
|
14
21
|
function wait(ms) {
|
|
15
22
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
23
|
}
|
|
@@ -108,6 +115,30 @@ export function createCodexEngine() {
|
|
|
108
115
|
resolveTurnDone = resolve;
|
|
109
116
|
rejectTurnDone = reject;
|
|
110
117
|
});
|
|
118
|
+
let subagentStallTimer;
|
|
119
|
+
const clearSubagentStallWatchdog = () => {
|
|
120
|
+
if (!subagentStallTimer)
|
|
121
|
+
return;
|
|
122
|
+
clearTimeout(subagentStallTimer);
|
|
123
|
+
subagentStallTimer = undefined;
|
|
124
|
+
};
|
|
125
|
+
// Re-armed on every observed subagent-thread status change so the
|
|
126
|
+
// deadline tracks the last activity, not the start of the wait.
|
|
127
|
+
const armSubagentStallWatchdog = () => {
|
|
128
|
+
clearSubagentStallWatchdog();
|
|
129
|
+
subagentStallTimer = setTimeout(() => {
|
|
130
|
+
subagentStallTimer = undefined;
|
|
131
|
+
console.warn(`[codex-engine] Background subagent thread(s) still tracked active ${CODEX_SUBAGENT_STALL_TIMEOUT_MS}ms after the turn completed — forcing session drain.`);
|
|
132
|
+
activeSubagentThreads.clear();
|
|
133
|
+
// Forced, unclean termination (a thread may still be alive
|
|
134
|
+
// server-side) — never report it as a normal completion, so
|
|
135
|
+
// auto-loop doesn't treat an orphaned run as forward progress.
|
|
136
|
+
mapperState.sawErrorResult = true;
|
|
137
|
+
if (waitingForBackgroundSubagents)
|
|
138
|
+
resolveTurnDone();
|
|
139
|
+
}, CODEX_SUBAGENT_STALL_TIMEOUT_MS);
|
|
140
|
+
subagentStallTimer.unref?.();
|
|
141
|
+
};
|
|
111
142
|
const finishBackgroundSubagent = (threadId, alreadyEmittedToolCallId) => {
|
|
112
143
|
const tracked = activeSubagentThreads.get(threadId);
|
|
113
144
|
if (!tracked)
|
|
@@ -122,8 +153,15 @@ export function createCodexEngine() {
|
|
|
122
153
|
taskType: tracked.taskType,
|
|
123
154
|
});
|
|
124
155
|
}
|
|
125
|
-
if (waitingForBackgroundSubagents
|
|
126
|
-
|
|
156
|
+
if (waitingForBackgroundSubagents) {
|
|
157
|
+
if (activeSubagentThreads.size === 0) {
|
|
158
|
+
clearSubagentStallWatchdog();
|
|
159
|
+
resolveTurnDone();
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
armSubagentStallWatchdog();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
127
165
|
};
|
|
128
166
|
const turnLiveness = createTurnLiveness({
|
|
129
167
|
timeoutMs: CODEX_TURN_IDLE_TIMEOUT_MS,
|
|
@@ -227,6 +265,7 @@ export function createCodexEngine() {
|
|
|
227
265
|
if (n.turn?.status === 'completed' && activeSubagentThreads.size > 0) {
|
|
228
266
|
waitingForBackgroundSubagents = true;
|
|
229
267
|
turnLiveness.pause();
|
|
268
|
+
armSubagentStallWatchdog();
|
|
230
269
|
}
|
|
231
270
|
else {
|
|
232
271
|
resolveTurnDone();
|
|
@@ -364,8 +403,21 @@ export function createCodexEngine() {
|
|
|
364
403
|
}
|
|
365
404
|
finally {
|
|
366
405
|
turnLiveness.stop();
|
|
406
|
+
clearSubagentStallWatchdog();
|
|
367
407
|
streamingBatcher.close();
|
|
368
408
|
iteratorRunning = false;
|
|
409
|
+
// Drain any outstanding approval/elicitation request (SDK terminated
|
|
410
|
+
// while awaiting a human decision) — without a response, Codex's own
|
|
411
|
+
// process would otherwise wait forever for a reply that never comes.
|
|
412
|
+
for (const pending of pendingByCallId.values()) {
|
|
413
|
+
try {
|
|
414
|
+
client.peer.respondError(pending.requestId, -32000, 'session ended');
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
// best-effort
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
pendingByCallId.clear();
|
|
369
421
|
client.close();
|
|
370
422
|
try {
|
|
371
423
|
child.kill('SIGTERM');
|
|
@@ -987,12 +987,7 @@ export function interruptAgent(workspaceId, options = {}) {
|
|
|
987
987
|
autoLoopService.disable(workspaceId, 'user-action');
|
|
988
988
|
}
|
|
989
989
|
}
|
|
990
|
-
|
|
991
|
-
export function stopAgent(workspaceId) {
|
|
992
|
-
const ctrl = controllers.get(workspaceId);
|
|
993
|
-
if (!ctrl) {
|
|
994
|
-
throw new Error(`No agent running for workspace '${workspaceId}'`);
|
|
995
|
-
}
|
|
990
|
+
async function stopController(workspaceId, ctrl) {
|
|
996
991
|
wakeupService.cancel(workspaceId, 'stopped');
|
|
997
992
|
// If the session was waiting on a question/permission, normalize the state
|
|
998
993
|
// synchronously so callers (archive, delete, manual stop) see a clean
|
|
@@ -1020,11 +1015,42 @@ export function stopAgent(workspaceId) {
|
|
|
1020
1015
|
controllers.delete(workspaceId);
|
|
1021
1016
|
// Manual stop should also drop any pending quota auto-resume.
|
|
1022
1017
|
quotaBackoffService.cancel(workspaceId, 'user');
|
|
1023
|
-
|
|
1024
|
-
|
|
1018
|
+
await ctrl.stop();
|
|
1019
|
+
}
|
|
1020
|
+
/** Gracefully stop an agent (the engine handles SIGTERM + SIGKILL). */
|
|
1021
|
+
export function stopAgent(workspaceId) {
|
|
1022
|
+
const ctrl = controllers.get(workspaceId);
|
|
1023
|
+
if (!ctrl) {
|
|
1024
|
+
throw new Error(`No agent running for workspace '${workspaceId}'`);
|
|
1025
|
+
}
|
|
1026
|
+
// Fire-and-forget: callers such as archive/delete must remain synchronous.
|
|
1027
|
+
void stopController(workspaceId, ctrl).catch((err) => {
|
|
1025
1028
|
console.error('[orchestrator] controller.stop failed:', err);
|
|
1026
1029
|
});
|
|
1027
1030
|
}
|
|
1031
|
+
/** Stop every live engine before process shutdown, with a bounded wait per engine. */
|
|
1032
|
+
export async function stopAllAgents(timeoutMs = 3_000) {
|
|
1033
|
+
const stops = [...controllers.entries()].map(async ([workspaceId, ctrl]) => {
|
|
1034
|
+
let timeout;
|
|
1035
|
+
try {
|
|
1036
|
+
await Promise.race([
|
|
1037
|
+
stopController(workspaceId, ctrl),
|
|
1038
|
+
new Promise((resolve) => {
|
|
1039
|
+
timeout = setTimeout(resolve, timeoutMs);
|
|
1040
|
+
timeout.unref?.();
|
|
1041
|
+
}),
|
|
1042
|
+
]);
|
|
1043
|
+
}
|
|
1044
|
+
catch (err) {
|
|
1045
|
+
console.error(`[orchestrator] Failed to stop '${workspaceId}' during shutdown:`, err);
|
|
1046
|
+
}
|
|
1047
|
+
finally {
|
|
1048
|
+
if (timeout)
|
|
1049
|
+
clearTimeout(timeout);
|
|
1050
|
+
}
|
|
1051
|
+
});
|
|
1052
|
+
await Promise.all(stops);
|
|
1053
|
+
}
|
|
1028
1054
|
/** Write a user message to the running agent. */
|
|
1029
1055
|
export async function sendMessage(workspaceId, content, expectedSessionId) {
|
|
1030
1056
|
const ctrl = controllers.get(workspaceId);
|
|
@@ -170,7 +170,6 @@ function fireOrSkip(id) {
|
|
|
170
170
|
}
|
|
171
171
|
let status = 'skipped-active';
|
|
172
172
|
if (!orchestrator.hasController(row.workspace_id)) {
|
|
173
|
-
status = 'fired';
|
|
174
173
|
try {
|
|
175
174
|
const globalSettings = settingsService.getGlobalSettings();
|
|
176
175
|
const projectSettings = settingsService.getProjectSettings(wsRow.project_path);
|
|
@@ -186,9 +185,11 @@ function fireOrSkip(id) {
|
|
|
186
185
|
// every fire" (clean context, no conversation continuity).
|
|
187
186
|
const resumeMode = row.agent_session_id !== null;
|
|
188
187
|
orchestrator.startAgent(row.workspace_id, worktreePath, row.prompt, wsRow.model, resumeMode, agentPermissionMode, row.agent_session_id ?? undefined, wsRow.reasoning_effort);
|
|
188
|
+
status = 'fired';
|
|
189
189
|
}
|
|
190
190
|
catch (err) {
|
|
191
191
|
console.error(`[cron-service] startAgent at fire time failed for cron '${id}':`, err);
|
|
192
|
+
status = 'start-failed';
|
|
192
193
|
}
|
|
193
194
|
}
|
|
194
195
|
const now = new Date();
|
|
@@ -3,7 +3,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { getProjectSettings } from './settings-service.js';
|
|
5
5
|
import { emitEphemeral } from './websocket-service.js';
|
|
6
|
-
import { getWorkspace, updateDevServerStatus } from './workspace-service.js';
|
|
6
|
+
import { getWorkspace, listWorkspaces, updateDevServerStatus } from './workspace-service.js';
|
|
7
7
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
8
8
|
/** Build a clean env for child processes, stripping Kobo-specific variables. */
|
|
9
9
|
function cleanEnv() {
|
|
@@ -312,6 +312,18 @@ export async function stopDevServer(workspaceId) {
|
|
|
312
312
|
emitEphemeral(workspaceId, 'devserver:status', status);
|
|
313
313
|
return status;
|
|
314
314
|
}
|
|
315
|
+
/** Stop direct and Docker dev servers for every persisted workspace. */
|
|
316
|
+
export async function stopAllDevServers() {
|
|
317
|
+
const workspaceIds = new Set([...listWorkspaces(true).map((workspace) => workspace.id), ...trackedProcesses.keys()]);
|
|
318
|
+
await Promise.all([...workspaceIds].map(async (workspaceId) => {
|
|
319
|
+
try {
|
|
320
|
+
await stopDevServer(workspaceId);
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
console.error(`[dev-server] Failed to stop '${workspaceId}' during shutdown:`, err);
|
|
324
|
+
}
|
|
325
|
+
}));
|
|
326
|
+
}
|
|
315
327
|
// ── Logs ───────────────────────────────────────────────────────────────────────
|
|
316
328
|
/**
|
|
317
329
|
* Get logs from running dev-server containers for a workspace.
|
|
@@ -6,6 +6,7 @@ import * as settingsService from './settings-service.js';
|
|
|
6
6
|
import { emitEphemeral } from './websocket-service.js';
|
|
7
7
|
const MIN_DELAY_SECONDS = 60;
|
|
8
8
|
const MAX_DELAY_SECONDS = 21600;
|
|
9
|
+
const ACTIVE_SESSION_RETRY_MS = 15_000;
|
|
9
10
|
const STALE_WAKEUP_GRACE_MS = 5 * 60 * 1000;
|
|
10
11
|
const AUTONOMOUS_LOOP_SENTINEL = '<<autonomous-loop-dynamic>>';
|
|
11
12
|
const AUTONOMOUS_LOOP_FALLBACK_PROMPT = 'Continue where you left off.';
|
|
@@ -19,6 +20,15 @@ function rowToPending(row) {
|
|
|
19
20
|
return null;
|
|
20
21
|
return { targetAt: row.target_at, reason: row.reason ?? undefined };
|
|
21
22
|
}
|
|
23
|
+
/** Keep a claimed wakeup durable while the session is still unavailable. */
|
|
24
|
+
function defer(workspaceId, row) {
|
|
25
|
+
const targetAt = new Date(Date.now() + ACTIVE_SESSION_RETRY_MS).toISOString();
|
|
26
|
+
getDb().prepare('UPDATE pending_wakeups SET target_at = ? WHERE workspace_id = ?').run(targetAt, workspaceId);
|
|
27
|
+
const timeout = setTimeout(() => fire(workspaceId), ACTIVE_SESSION_RETRY_MS);
|
|
28
|
+
timeout.unref?.();
|
|
29
|
+
timers.set(workspaceId, timeout);
|
|
30
|
+
emitEphemeral(workspaceId, 'wakeup:scheduled', { targetAt, reason: row.reason ?? undefined });
|
|
31
|
+
}
|
|
22
32
|
/** Schedule a wakeup for the given workspace. Replaces any existing pending wakeup. */
|
|
23
33
|
export function schedule(workspaceId, delaySeconds, prompt, reason, agentSessionId) {
|
|
24
34
|
try {
|
|
@@ -118,21 +128,12 @@ export function rehydrate() {
|
|
|
118
128
|
function fire(workspaceId) {
|
|
119
129
|
try {
|
|
120
130
|
const db = getDb();
|
|
121
|
-
|
|
122
|
-
// cancel() can't race us between the read and the act. If we come out
|
|
123
|
-
// with a row, it's ours exclusively; no other caller will ever see it.
|
|
124
|
-
const row = db.transaction(() => {
|
|
125
|
-
const r = db.prepare('SELECT * FROM pending_wakeups WHERE workspace_id = ?').get(workspaceId);
|
|
126
|
-
if (r) {
|
|
127
|
-
db.prepare('DELETE FROM pending_wakeups WHERE workspace_id = ?').run(workspaceId);
|
|
128
|
-
}
|
|
129
|
-
return r;
|
|
130
|
-
})();
|
|
131
|
+
const row = db.prepare('SELECT * FROM pending_wakeups WHERE workspace_id = ?').get(workspaceId);
|
|
131
132
|
timers.delete(workspaceId);
|
|
132
133
|
if (!row)
|
|
133
134
|
return;
|
|
134
135
|
if (orchestrator.hasController(workspaceId)) {
|
|
135
|
-
|
|
136
|
+
defer(workspaceId, row);
|
|
136
137
|
return;
|
|
137
138
|
}
|
|
138
139
|
const wsRow = db
|
|
@@ -155,12 +156,13 @@ function fire(workspaceId) {
|
|
|
155
156
|
const agentPermissionMode = stored === 'plan' || stored === 'strict' || stored === 'interactive' ? stored : 'bypass';
|
|
156
157
|
try {
|
|
157
158
|
orchestrator.startAgent(workspaceId, worktreePath, row.prompt, wsRow.model, true, agentPermissionMode, row.agent_session_id ?? undefined, wsRow.reasoning_effort);
|
|
159
|
+
db.prepare('DELETE FROM pending_wakeups WHERE workspace_id = ?').run(workspaceId);
|
|
158
160
|
emitEphemeral(workspaceId, 'wakeup:fired', {});
|
|
159
161
|
}
|
|
160
162
|
catch (err) {
|
|
161
163
|
const message = err instanceof Error ? err.message : String(err);
|
|
162
164
|
console.error(`[wakeup-service] startAgent at fire time failed for '${workspaceId}':`, message);
|
|
163
|
-
|
|
165
|
+
defer(workspaceId, row);
|
|
164
166
|
}
|
|
165
167
|
}
|
|
166
168
|
catch (err) {
|
|
@@ -2,6 +2,7 @@ import { execFile as execFileCb, execFileSync } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
|
+
import { resolvePathInside } from './safe-path.js';
|
|
5
6
|
const execFileAsync = promisify(execFileCb);
|
|
6
7
|
const READ_ONLY_GIT_TIMEOUT_MS = 15_000;
|
|
7
8
|
function git(repoPath, args) {
|
|
@@ -854,6 +855,7 @@ export function getChangedFilesBetween(repoPath, fromRef, toRef) {
|
|
|
854
855
|
* feedback in the UI.
|
|
855
856
|
*/
|
|
856
857
|
export function rollbackFile(repoPath, branchName, filePath, remote = 'origin') {
|
|
858
|
+
const absPath = resolvePathInside(repoPath, filePath);
|
|
857
859
|
const remoteRef = `${remote}/${branchName}`;
|
|
858
860
|
let remoteRefExists = false;
|
|
859
861
|
try {
|
|
@@ -884,7 +886,6 @@ export function rollbackFile(repoPath, branchName, filePath, remote = 'origin')
|
|
|
884
886
|
// with a previous rollback, stale UI list, manual rm), we still return
|
|
885
887
|
// 'deleted' since the end state matches the user's intent. `rmSync`
|
|
886
888
|
// over `git clean -f` keeps the action narrow to one file.
|
|
887
|
-
const absPath = join(repoPath, filePath);
|
|
888
889
|
if (existsSync(absPath)) {
|
|
889
890
|
rmSync(absPath, { force: true });
|
|
890
891
|
}
|
|
@@ -896,7 +897,7 @@ export const rollbackFileToRemote = rollbackFile;
|
|
|
896
897
|
/** Get the current content of a file in the worktree. Returns null if the file doesn't exist. */
|
|
897
898
|
export function getFileContent(repoPath, filePath) {
|
|
898
899
|
try {
|
|
899
|
-
return readFileSync(
|
|
900
|
+
return readFileSync(resolvePathInside(repoPath, filePath), 'utf-8');
|
|
900
901
|
}
|
|
901
902
|
catch {
|
|
902
903
|
return null;
|