autodev-cli 1.4.0 → 1.4.3
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/bin/autodev.js +0 -0
- package/out/agentBackup/archive.d.ts +44 -0
- package/out/agentBackup/archive.js +131 -0
- package/out/agentBackup/archive.js.map +1 -0
- package/out/agentBackup/export.d.ts +18 -0
- package/out/agentBackup/export.js +92 -0
- package/out/agentBackup/export.js.map +1 -0
- package/out/agentBackup/import.d.ts +21 -0
- package/out/agentBackup/import.js +40 -0
- package/out/agentBackup/import.js.map +1 -0
- package/out/agentBackup/index.d.ts +6 -0
- package/out/agentBackup/index.js +11 -0
- package/out/agentBackup/index.js.map +1 -0
- package/out/agentBackup/layout.d.ts +30 -0
- package/out/agentBackup/layout.js +126 -0
- package/out/agentBackup/layout.js.map +1 -0
- package/out/agentBackup/manifest.d.ts +24 -0
- package/out/agentBackup/manifest.js +70 -0
- package/out/agentBackup/manifest.js.map +1 -0
- package/out/agentBackup/opencodeDb.d.ts +20 -0
- package/out/agentBackup/opencodeDb.js +213 -0
- package/out/agentBackup/opencodeDb.js.map +1 -0
- package/out/agentBackup/sessionProviders.d.ts +35 -0
- package/out/agentBackup/sessionProviders.js +263 -0
- package/out/agentBackup/sessionProviders.js.map +1 -0
- package/out/agentBackup/upload.d.ts +9 -0
- package/out/agentBackup/upload.js +121 -0
- package/out/agentBackup/upload.js.map +1 -0
- package/out/cli.d.ts +1 -0
- package/out/cli.js +8 -0
- package/out/cli.js.map +1 -1
- package/out/cliExit.d.ts +34 -0
- package/out/cliExit.js +159 -0
- package/out/cliExit.js.map +1 -0
- package/out/commands/config.d.ts +2 -0
- package/out/commands/config.js +7 -7
- package/out/commands/config.js.map +1 -1
- package/out/commands/connect.d.ts +2 -0
- package/out/commands/connect.js +11 -0
- package/out/commands/connect.js.map +1 -1
- package/out/commands/export.d.ts +2 -0
- package/out/commands/export.js +79 -0
- package/out/commands/export.js.map +1 -0
- package/out/commands/import.d.ts +2 -0
- package/out/commands/import.js +92 -0
- package/out/commands/import.js.map +1 -0
- package/out/commands/init.d.ts +16 -0
- package/out/commands/init.js +9 -5
- package/out/commands/init.js.map +1 -1
- package/out/commands/resume.d.ts +2 -0
- package/out/commands/resume.js +65 -0
- package/out/commands/resume.js.map +1 -0
- package/out/commands/sessions.d.ts +2 -0
- package/out/commands/sessions.js +64 -0
- package/out/commands/sessions.js.map +1 -0
- package/out/commands/start.d.ts +2 -0
- package/out/commands/start.js +40 -7
- package/out/commands/start.js.map +1 -1
- package/out/commands/status.d.ts +2 -0
- package/out/commands/status.js +3 -3
- package/out/commands/status.js.map +1 -1
- package/out/commands/tailOutput.d.ts +12 -0
- package/out/commands/up.d.ts +3 -0
- package/out/configManager.d.ts +42 -0
- package/out/configManager.js +430 -0
- package/out/configManager.js.map +1 -0
- package/out/connect.d.ts +4 -0
- package/out/connect.js +7 -7
- package/out/connect.js.map +1 -1
- package/out/core/adapters.d.ts +34 -0
- package/out/core/adapters.js +84 -0
- package/out/core/adapters.js.map +1 -0
- package/out/core/commandHelpers.d.ts +12 -0
- package/out/core/commandHelpers.js +96 -0
- package/out/core/commandHelpers.js.map +1 -0
- package/out/core/projectMcp.d.ts +25 -0
- package/out/core/projectMcp.js +144 -0
- package/out/core/projectMcp.js.map +1 -0
- package/out/core/provider/BaseProvider.d.ts +14 -0
- package/out/core/provider/BaseProvider.js +25 -0
- package/out/core/provider/BaseProvider.js.map +1 -0
- package/out/core/provider/ProviderRegistry.d.ts +12 -0
- package/out/core/provider/ProviderRegistry.js +40 -0
- package/out/core/provider/ProviderRegistry.js.map +1 -0
- package/out/core/provider/contract.d.ts +62 -0
- package/out/core/provider/contract.js +9 -0
- package/out/core/provider/contract.js.map +1 -0
- package/out/core/provider/implementations.d.ts +54 -0
- package/out/core/provider/implementations.js +147 -0
- package/out/core/provider/implementations.js.map +1 -0
- package/out/core/settingsLoader.d.ts +221 -0
- package/out/core/settingsLoader.js +176 -0
- package/out/core/settingsLoader.js.map +1 -0
- package/out/discordGateway.d.ts +26 -0
- package/out/discordGateway.js +230 -0
- package/out/discordGateway.js.map +1 -0
- package/out/discordPoller.d.ts +28 -0
- package/out/discordPoller.js +247 -0
- package/out/discordPoller.js.map +1 -0
- package/out/dispatcher.d.ts +12 -0
- package/out/dispatcher.js +214 -0
- package/out/dispatcher.js.map +1 -0
- package/out/emailPoller.d.ts +42 -0
- package/out/emailPoller.js +221 -0
- package/out/emailPoller.js.map +1 -0
- package/out/git/gitService.d.ts +36 -0
- package/out/git/gitService.js +165 -0
- package/out/git/gitService.js.map +1 -0
- package/out/hookEventNormalizer.d.ts +39 -0
- package/out/hookEventNormalizer.js +397 -0
- package/out/hookEventNormalizer.js.map +1 -0
- package/out/hooksManager.d.ts +25 -0
- package/out/hooksManager.js +471 -0
- package/out/hooksManager.js.map +1 -0
- package/out/launchIde.d.ts +14 -0
- package/out/logger.d.ts +12 -0
- package/out/mcpEmailTest.d.ts +29 -0
- package/out/mcpEmailTest.js +245 -0
- package/out/mcpEmailTest.js.map +1 -0
- package/out/mcpInstallCheck.d.ts +23 -0
- package/out/mcpInstallCheck.js +219 -0
- package/out/mcpInstallCheck.js.map +1 -0
- package/out/mcpManager.d.ts +35 -0
- package/out/mcpManager.js +371 -0
- package/out/mcpManager.js.map +1 -0
- package/out/messageBuilder.d.ts +54 -0
- package/out/messageBuilder.js +373 -0
- package/out/messageBuilder.js.map +1 -0
- package/out/openCodeHooksManager.d.ts +23 -0
- package/out/openCodeHooksManager.js +511 -0
- package/out/openCodeHooksManager.js.map +1 -0
- package/out/periodicActions.d.ts +63 -0
- package/out/periodicActions.js +237 -0
- package/out/periodicActions.js.map +1 -0
- package/out/profileBuilder.d.ts +29 -0
- package/out/profileBuilder.js +366 -0
- package/out/profileBuilder.js.map +1 -0
- package/out/prompt.d.ts +12 -0
- package/out/prompt.js +18 -0
- package/out/prompt.js.map +1 -0
- package/out/protocolSections.d.ts +26 -0
- package/out/protocolSections.js +209 -0
- package/out/protocolSections.js.map +1 -0
- package/out/providers/claudeCliProvider.d.ts +71 -0
- package/out/providers/claudeCliProvider.js +425 -0
- package/out/providers/claudeCliProvider.js.map +1 -0
- package/out/providers/claudeTuiProvider.d.ts +23 -0
- package/out/providers/claudeTuiProvider.js +296 -0
- package/out/providers/claudeTuiProvider.js.map +1 -0
- package/out/providers/copilotCliProvider.d.ts +16 -0
- package/out/providers/copilotCliProvider.js +44 -0
- package/out/providers/copilotCliProvider.js.map +1 -0
- package/out/providers/copilotSdkProvider.d.ts +12 -0
- package/out/providers/copilotSdkProvider.js +445 -0
- package/out/providers/copilotSdkProvider.js.map +1 -0
- package/out/providers/grokTuiProvider.d.ts +14 -0
- package/out/providers/grokTuiProvider.js +271 -0
- package/out/providers/grokTuiProvider.js.map +1 -0
- package/out/providers/opencodeCliProvider.d.ts +29 -0
- package/out/providers/opencodeCliProvider.js +199 -0
- package/out/providers/opencodeCliProvider.js.map +1 -0
- package/out/providers/opencodeSdkProvider.d.ts +22 -0
- package/out/providers/opencodeSdkProvider.js +557 -0
- package/out/providers/opencodeSdkProvider.js.map +1 -0
- package/out/providers.d.ts +9 -0
- package/out/providers.js +44 -0
- package/out/providers.js.map +1 -0
- package/out/rateLimit.d.ts +18 -0
- package/out/rateLimit.js +90 -0
- package/out/rateLimit.js.map +1 -0
- package/out/rdp/auth.d.ts +55 -0
- package/out/rdp/auth.js +197 -0
- package/out/rdp/auth.js.map +1 -0
- package/out/rdp/bridge.d.ts +86 -0
- package/out/rdp/bridge.js +1398 -0
- package/out/rdp/bridge.js.map +1 -0
- package/out/rdp/constants.d.ts +86 -0
- package/out/rdp/constants.js +182 -0
- package/out/rdp/constants.js.map +1 -0
- package/out/rdp/index.d.ts +7 -0
- package/out/rdp/index.js +14 -0
- package/out/rdp/index.js.map +1 -0
- package/out/rdp/session.d.ts +30 -0
- package/out/rdp/session.js +196 -0
- package/out/rdp/session.js.map +1 -0
- package/out/rdp/types.d.ts +27 -0
- package/out/rdp/types.js +6 -0
- package/out/rdp/types.js.map +1 -0
- package/out/sdk/index.d.ts +22 -0
- package/out/sdk/index.js +81 -0
- package/out/sdk/index.js.map +1 -0
- package/out/sessionState.d.ts +54 -0
- package/out/sessionState.js +284 -0
- package/out/sessionState.js.map +1 -0
- package/out/sessions.d.ts +11 -0
- package/out/sessions.js +32 -0
- package/out/sessions.js.map +1 -0
- package/out/taskLoop.d.ts +152 -0
- package/out/taskLoop.js +2505 -0
- package/out/taskLoop.js.map +1 -0
- package/out/todo.d.ts +42 -0
- package/out/todo.js +311 -0
- package/out/todo.js.map +1 -0
- package/out/todoWriteManager.d.ts +26 -0
- package/out/todoWriteManager.js +44 -0
- package/out/todoWriteManager.js.map +1 -0
- package/out/vnc/auth.d.ts +52 -0
- package/out/vnc/auth.js +181 -0
- package/out/vnc/auth.js.map +1 -0
- package/out/vnc/bridge.d.ts +40 -0
- package/out/vnc/bridge.js +540 -0
- package/out/vnc/bridge.js.map +1 -0
- package/out/vnc/constants.d.ts +8 -0
- package/out/vnc/constants.js +34 -0
- package/out/vnc/constants.js.map +1 -0
- package/out/vnc/des.d.ts +6 -0
- package/out/vnc/des.js +93 -0
- package/out/vnc/des.js.map +1 -0
- package/out/vnc/index.d.ts +7 -0
- package/out/vnc/index.js +13 -0
- package/out/vnc/index.js.map +1 -0
- package/out/vnc/session.d.ts +18 -0
- package/out/vnc/session.js +193 -0
- package/out/vnc/session.js.map +1 -0
- package/out/vnc/types.d.ts +16 -0
- package/out/vnc/types.js +6 -0
- package/out/vnc/types.js.map +1 -0
- package/out/webSocketPoller.d.ts +95 -0
- package/out/webSocketPoller.js +986 -0
- package/out/webSocketPoller.js.map +1 -0
- package/out/webhook.d.ts +37 -0
- package/out/webhook.js +265 -0
- package/out/webhook.js.map +1 -0
- package/out/webhookPoller.d.ts +40 -0
- package/out/webhookPoller.js +378 -0
- package/out/webhookPoller.js.map +1 -0
- package/package.json +54 -41
package/out/taskLoop.js
ADDED
|
@@ -0,0 +1,2505 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.taskLoopRunner = exports.TaskLoopRunner = void 0;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const os = __importStar(require("os"));
|
|
40
|
+
const crypto = __importStar(require("crypto"));
|
|
41
|
+
const child_process_1 = require("child_process");
|
|
42
|
+
const todo_1 = require("./todo");
|
|
43
|
+
const todoWriteManager_1 = require("./todoWriteManager");
|
|
44
|
+
const prompt_1 = require("./prompt");
|
|
45
|
+
const messageBuilder_1 = require("./messageBuilder");
|
|
46
|
+
const webhook_1 = require("./webhook");
|
|
47
|
+
const settingsLoader_1 = require("./core/settingsLoader");
|
|
48
|
+
const dispatcher_1 = require("./dispatcher");
|
|
49
|
+
const opencodeCliProvider_1 = require("./providers/opencodeCliProvider");
|
|
50
|
+
const openCodeHooksManager_1 = require("./openCodeHooksManager");
|
|
51
|
+
const claudeCliProvider_1 = require("./providers/claudeCliProvider");
|
|
52
|
+
const claudeTuiProvider_1 = require("./providers/claudeTuiProvider");
|
|
53
|
+
const copilotSdkProvider_1 = require("./providers/copilotSdkProvider");
|
|
54
|
+
const opencodeSdkProvider_1 = require("./providers/opencodeSdkProvider");
|
|
55
|
+
const sessionState_1 = require("./sessionState");
|
|
56
|
+
const dispatcher_2 = require("./dispatcher");
|
|
57
|
+
const providers_1 = require("./providers");
|
|
58
|
+
const discordPoller_1 = require("./discordPoller");
|
|
59
|
+
const periodicActions_1 = require("./periodicActions");
|
|
60
|
+
const discordGateway_1 = require("./discordGateway");
|
|
61
|
+
const webhookPoller_1 = require("./webhookPoller");
|
|
62
|
+
const emailPoller_1 = require("./emailPoller");
|
|
63
|
+
const projectMcp_1 = require("./core/projectMcp");
|
|
64
|
+
const configManager_1 = require("./configManager");
|
|
65
|
+
const agentBackup_1 = require("./agentBackup");
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Rate-limit + context-length errors
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
const rateLimit_1 = require("./rateLimit");
|
|
70
|
+
const cliExit_1 = require("./cliExit");
|
|
71
|
+
class ContextLengthError extends Error {
|
|
72
|
+
rawMessage;
|
|
73
|
+
constructor(rawMessage) {
|
|
74
|
+
super(rawMessage);
|
|
75
|
+
this.rawMessage = rawMessage;
|
|
76
|
+
this.name = 'ContextLengthError';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Thrown when Claude's autocompact is thrashing — /clear is needed. */
|
|
80
|
+
class ThrashingError extends ContextLengthError {
|
|
81
|
+
constructor(rawMessage) {
|
|
82
|
+
super(rawMessage);
|
|
83
|
+
this.name = 'ThrashingError';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// RetryScheduler — single clearable timer for rate-limit resume
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
class RetryScheduler {
|
|
90
|
+
_timer = null;
|
|
91
|
+
schedule(ms, cb) {
|
|
92
|
+
this.clear();
|
|
93
|
+
this._timer = setTimeout(cb, ms);
|
|
94
|
+
}
|
|
95
|
+
clear() {
|
|
96
|
+
if (this._timer !== null) {
|
|
97
|
+
clearTimeout(this._timer);
|
|
98
|
+
this._timer = null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function sleep(ms) {
|
|
103
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Read a CLI stdout file and return its content as a UTF-8 string.
|
|
107
|
+
* Handles all BOM variants that PowerShell may write:
|
|
108
|
+
* \xFF\xFE → UTF-16 LE (Tee-Object default on PS5)
|
|
109
|
+
* \xEF\xBB\xBF → UTF-8 with BOM (Out-File -Encoding UTF8 on PS5)
|
|
110
|
+
* no BOM → plain UTF-8 (PS7)
|
|
111
|
+
*/
|
|
112
|
+
function readOutputFile(filePath) {
|
|
113
|
+
const raw = fs.readFileSync(filePath);
|
|
114
|
+
if (raw.length === 0) {
|
|
115
|
+
return '';
|
|
116
|
+
}
|
|
117
|
+
if (raw[0] === 0xFF && raw[1] === 0xFE) {
|
|
118
|
+
// UTF-16 LE
|
|
119
|
+
return raw.subarray(2).toString('utf16le').trim();
|
|
120
|
+
}
|
|
121
|
+
if (raw[0] === 0xEF && raw[1] === 0xBB && raw[2] === 0xBF) {
|
|
122
|
+
// UTF-8 BOM
|
|
123
|
+
return raw.subarray(3).toString('utf8').trim();
|
|
124
|
+
}
|
|
125
|
+
return raw.toString('utf8').trim();
|
|
126
|
+
}
|
|
127
|
+
/** First line of task text, capped at 200 chars — safe to post to Discord. */
|
|
128
|
+
function discordLabel(taskText) {
|
|
129
|
+
const first = taskText.split('\n')[0].trim();
|
|
130
|
+
return first.length > 200 ? first.slice(0, 197) + '\u2026' : first;
|
|
131
|
+
}
|
|
132
|
+
function resolveGitInfo(workDir) {
|
|
133
|
+
const run = (cmd) => {
|
|
134
|
+
try {
|
|
135
|
+
return (0, child_process_1.execSync)(cmd, { cwd: workDir, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return '';
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
return {
|
|
142
|
+
gitRepo: run('git remote get-url origin'),
|
|
143
|
+
gitBranch: run('git rev-parse --abbrev-ref HEAD'),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
class TaskLoopRunner {
|
|
147
|
+
_state = 'idle';
|
|
148
|
+
_currentTask;
|
|
149
|
+
_taskWatcher;
|
|
150
|
+
_iterations = 0;
|
|
151
|
+
_cb;
|
|
152
|
+
_webhook = null;
|
|
153
|
+
_settings;
|
|
154
|
+
_workspaceRoot;
|
|
155
|
+
_discordPoller = null;
|
|
156
|
+
_discordGateway = null;
|
|
157
|
+
_webhookPoller = null;
|
|
158
|
+
_emailPoller = null;
|
|
159
|
+
/** True after we last told the server "all_tasks_done" — cleared on task_start.
|
|
160
|
+
* Used to re-assert idle state on WS reconnect, otherwise agent_online flips
|
|
161
|
+
* the server-side status back to 'active' even though we have no work. */
|
|
162
|
+
_idleNotified = false;
|
|
163
|
+
_pollerIntervals = [];
|
|
164
|
+
_hooksFileOffset = 0;
|
|
165
|
+
/** Recently-forwarded hook-line hashes → first-seen timestamp (ms).
|
|
166
|
+
* Used to suppress byte-identical hook events that get appended multiple
|
|
167
|
+
* times to the shared JSONL (Copilot CLI fires the same hook from every
|
|
168
|
+
* parallel session in the same workspace, all writing to one homedir
|
|
169
|
+
* file). Entries older than HOOKS_DEDUPE_WINDOW_MS are pruned each tick. */
|
|
170
|
+
_hookLineSeen = new Map();
|
|
171
|
+
_taskCompletionAbort = null;
|
|
172
|
+
_retryScheduler = new RetryScheduler();
|
|
173
|
+
_resumeResolve = null;
|
|
174
|
+
/** Resolves the idle no-task sleep early when a poller appends a new task. */
|
|
175
|
+
_idleSleepWake = null;
|
|
176
|
+
_resumeAt;
|
|
177
|
+
/** When fallback is active: the saved main provider and when to switch back. */
|
|
178
|
+
_mainProviderBeforeFallback = null;
|
|
179
|
+
_mainProviderResumeAt;
|
|
180
|
+
_gitRepo = '';
|
|
181
|
+
_gitBranch = '';
|
|
182
|
+
_hostname = '';
|
|
183
|
+
_completedCount = 0;
|
|
184
|
+
_failedCount = 0;
|
|
185
|
+
_loopStartTime = 0;
|
|
186
|
+
/** Task lines that have already had /compact run — prevents infinite compact loops. */
|
|
187
|
+
_compactedTaskLines = new Set();
|
|
188
|
+
/** True while a compact operation is in progress — prevents nested/recursive compacts. */
|
|
189
|
+
_compacting = false;
|
|
190
|
+
/** Timestamp (ms) when compact was last run — used to throttle auto-compact (minimum 2min between compacts). */
|
|
191
|
+
_lastCompactTime = 0;
|
|
192
|
+
/** Dispatch attempt counter per task key (id or text). After 3 failed attempts the
|
|
193
|
+
* loop force-marks the task done so it doesn't block the queue indefinitely. */
|
|
194
|
+
_taskAttempts = new Map();
|
|
195
|
+
/** Counts completed tasks since the last auto-compact run. */
|
|
196
|
+
_autoCompactCounter = 0;
|
|
197
|
+
/** Counts completed tasks since the last session reset. */
|
|
198
|
+
_resetSessionCounter = 0;
|
|
199
|
+
/** Counts tasks dispatched since the last profile-included send. */
|
|
200
|
+
_profileSentCounter = 0;
|
|
201
|
+
/** Manages all "every N tasks" periodic action counters. */
|
|
202
|
+
_periodicMgr = new periodicActions_1.PeriodicActionManager();
|
|
203
|
+
get state() { return this._state; }
|
|
204
|
+
get currentTask() { return this._currentTask; }
|
|
205
|
+
get resumeAt() { return this._resumeAt; }
|
|
206
|
+
/** Manually trigger a /compact on the current session for the given provider/root. */
|
|
207
|
+
async compact(root, provider) {
|
|
208
|
+
const log = (m) => this._cb?.log(m);
|
|
209
|
+
// Guard: prevent infinite compact loops
|
|
210
|
+
if (this._compacting) {
|
|
211
|
+
log('⚠️ Compact skipped: already compacting (infinite loop prevention)');
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
this._compacting = true;
|
|
215
|
+
log(`🗜 Manual compact triggered (provider: ${provider})`);
|
|
216
|
+
try {
|
|
217
|
+
if (provider === 'claude-cli') {
|
|
218
|
+
let sid = (0, sessionState_1.getSessionId)(root, 'claude-cli');
|
|
219
|
+
if (!sid) {
|
|
220
|
+
sid = (0, dispatcher_1.findLatestClaudeSession)(root);
|
|
221
|
+
}
|
|
222
|
+
if (sid) {
|
|
223
|
+
await (0, claudeCliProvider_1.runClaudeCompact)(sid, root, log);
|
|
224
|
+
log('🗜 Compact complete');
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
log('⚠️ Compact: no Claude session ID found');
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else if (provider === 'claude-tui') {
|
|
231
|
+
let sid = (0, sessionState_1.getSessionId)(root, 'claude-tui');
|
|
232
|
+
if (!sid) {
|
|
233
|
+
sid = (0, claudeTuiProvider_1.getClaudeTuiLatestSessionId)(root);
|
|
234
|
+
}
|
|
235
|
+
if (sid) {
|
|
236
|
+
await (0, claudeTuiProvider_1.runClaudeTuiCompact)(root, sid, log);
|
|
237
|
+
log('🗜 Compact complete');
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
log('⚠️ Compact: no Claude TUI session ID found');
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
else if (provider === 'opencode-cli') {
|
|
244
|
+
let sid = (0, sessionState_1.getSessionId)(root, 'opencode-cli');
|
|
245
|
+
if (!sid) {
|
|
246
|
+
sid = await (0, opencodeCliProvider_1.getLatestOpenCodeSessionId)(root, log);
|
|
247
|
+
}
|
|
248
|
+
if (sid) {
|
|
249
|
+
await (0, opencodeCliProvider_1.runOpenCodeCompact)(sid, root, log);
|
|
250
|
+
log('🗜 Compact complete');
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
log('⚠️ Compact: no OpenCode session ID found');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
else if (provider === 'opencode-sdk') {
|
|
257
|
+
await (0, opencodeSdkProvider_1.runOpencodeSdkCompact)(root, log);
|
|
258
|
+
log('🗜 Compact complete (opencode-sdk)');
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
log(`⚠️ Compact not supported for provider: ${provider}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
catch (e) {
|
|
265
|
+
log(`⚠️ Compact failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
this._compacting = false;
|
|
269
|
+
this._lastCompactTime = Date.now();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/** Manually trigger a /clear on the current Claude session for the given provider/root. */
|
|
273
|
+
async clearSession(root, provider) {
|
|
274
|
+
const log = (m) => this._cb?.log(m);
|
|
275
|
+
log(`🗑 Manual /clear triggered (provider: ${provider})`);
|
|
276
|
+
try {
|
|
277
|
+
if (provider === 'claude-cli') {
|
|
278
|
+
let sid = (0, sessionState_1.getSessionId)(root, 'claude-cli');
|
|
279
|
+
if (!sid) {
|
|
280
|
+
sid = (0, dispatcher_1.findLatestClaudeSession)(root);
|
|
281
|
+
}
|
|
282
|
+
if (sid) {
|
|
283
|
+
await (0, claudeCliProvider_1.runClaudeClear)(sid, root, log);
|
|
284
|
+
(0, sessionState_1.clearSessionId)(root, 'claude-cli');
|
|
285
|
+
log('🗑 /clear complete — session reset');
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
log('⚠️ /clear: no Claude session ID found');
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
else if (provider === 'claude-tui') {
|
|
292
|
+
let sid = (0, sessionState_1.getSessionId)(root, 'claude-tui');
|
|
293
|
+
if (!sid) {
|
|
294
|
+
sid = (0, claudeTuiProvider_1.getClaudeTuiLatestSessionId)(root);
|
|
295
|
+
}
|
|
296
|
+
if (sid) {
|
|
297
|
+
await (0, claudeTuiProvider_1.runClaudeTuiClear)(root, sid, log);
|
|
298
|
+
(0, sessionState_1.clearSessionId)(root, 'claude-tui');
|
|
299
|
+
log('🗑 /clear (TUI) complete — session reset');
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
log('⚠️ /clear: no Claude TUI session ID found');
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
log(`⚠️ /clear not supported for provider: ${provider}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
catch (e) {
|
|
310
|
+
log(`⚠️ /clear failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/** Resume the loop after a rate-limit pause. Clears the scheduled timer. */
|
|
314
|
+
retry() {
|
|
315
|
+
if (this._state !== 'paused') {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
this._retryScheduler.clear();
|
|
319
|
+
this._resumeAt = undefined;
|
|
320
|
+
this._mainProviderBeforeFallback = null;
|
|
321
|
+
this._mainProviderResumeAt = undefined;
|
|
322
|
+
this._setState('running');
|
|
323
|
+
const r = this._resumeResolve;
|
|
324
|
+
this._resumeResolve = null;
|
|
325
|
+
r?.();
|
|
326
|
+
}
|
|
327
|
+
async start(callbacks) {
|
|
328
|
+
if (this._state === 'running') {
|
|
329
|
+
callbacks.log('Task loop already running');
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
this._cb = callbacks;
|
|
333
|
+
this._iterations = 0;
|
|
334
|
+
this._compactedTaskLines.clear();
|
|
335
|
+
this._taskAttempts.clear();
|
|
336
|
+
this._autoCompactCounter = 0;
|
|
337
|
+
this._resetSessionCounter = 0;
|
|
338
|
+
this._profileSentCounter = 0;
|
|
339
|
+
this._periodicMgr.resetAndPersist(callbacks.workspaceRoot);
|
|
340
|
+
this._hookLineSeen.clear();
|
|
341
|
+
this._setState('running');
|
|
342
|
+
const settings = (0, settingsLoader_1.loadSettingsForRoot)(callbacks.workspaceRoot);
|
|
343
|
+
const root = callbacks.workspaceRoot;
|
|
344
|
+
if (!root) {
|
|
345
|
+
callbacks.log('No workspace folder open');
|
|
346
|
+
this._setState('idle');
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
this._settings = settings;
|
|
350
|
+
this._workspaceRoot = root;
|
|
351
|
+
this._completedCount = 0;
|
|
352
|
+
this._failedCount = 0;
|
|
353
|
+
this._loopStartTime = Date.now();
|
|
354
|
+
this._hostname = os.hostname();
|
|
355
|
+
const git = resolveGitInfo(root);
|
|
356
|
+
this._gitRepo = git.gitRepo;
|
|
357
|
+
this._gitBranch = git.gitBranch;
|
|
358
|
+
this._webhook = (settings.serverBaseUrl && settings.webhookSlug)
|
|
359
|
+
? new webhook_1.WebhookClient(settings.serverBaseUrl.replace(/\/$/, '') + '/webhook/' + settings.webhookSlug, settings.serverApiKey, settings.webhookSlug)
|
|
360
|
+
: null;
|
|
361
|
+
this._webhook?.setMeta({ provider: settings.provider, workDir: root, hostname: this._hostname, gitRepo: this._gitRepo, gitBranch: this._gitBranch });
|
|
362
|
+
this._discordPoller = (settings.discordToken && settings.discordChannelId && settings.discordOwners)
|
|
363
|
+
? new discordPoller_1.DiscordPoller(settings.discordToken, settings.discordChannelId, settings.discordOwners)
|
|
364
|
+
: null;
|
|
365
|
+
this._discordGateway = settings.discordToken
|
|
366
|
+
? new discordGateway_1.DiscordGateway(settings.discordToken)
|
|
367
|
+
: null;
|
|
368
|
+
this._webhookPoller = (settings.serverBaseUrl && settings.serverApiKey && settings.webhookSlug)
|
|
369
|
+
? new webhookPoller_1.WebhookPoller(settings.serverBaseUrl, settings.serverApiKey, settings.webhookSlug)
|
|
370
|
+
: null;
|
|
371
|
+
// Email task ingestion — pulls IMAP creds from the Email MCP entry's env
|
|
372
|
+
// block. Disabled unless AUTODEV_EMAIL_RECEIVE_TASKS is "true".
|
|
373
|
+
this._emailPoller = this._buildEmailPoller(settings);
|
|
374
|
+
// When the poller is WebSocket-backed, route outbound events through the
|
|
375
|
+
// same WS connection instead of HTTP POST (which fails for ws:// URLs).
|
|
376
|
+
if (this._webhook && this._webhookPoller?.isWebSocket) {
|
|
377
|
+
this._webhook.setWsSender((frame) => this._webhookPoller.sendFrame(frame));
|
|
378
|
+
// Re-send agent_online once the WS connection is actually established so
|
|
379
|
+
// the server can record the VNC host/port from the live connection context.
|
|
380
|
+
this._webhookPoller.setOnConnect(() => {
|
|
381
|
+
// If the loop is no longer running (stopped or stopping), re-assert
|
|
382
|
+
// offline status on reconnect instead of claiming agent_online.
|
|
383
|
+
if (this._state === 'idle' || this._state === 'stopping') {
|
|
384
|
+
this._notifyWebhook('agent_offline', {
|
|
385
|
+
workDir: this._workspaceRoot ?? '',
|
|
386
|
+
gitRepo: this._gitRepo,
|
|
387
|
+
gitBranch: this._gitBranch,
|
|
388
|
+
});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
this._notifyWebhook('agent_online', {
|
|
392
|
+
hostname: this._hostname,
|
|
393
|
+
workDir: this._workspaceRoot ?? '',
|
|
394
|
+
gitRepo: this._gitRepo,
|
|
395
|
+
gitBranch: this._gitBranch,
|
|
396
|
+
vncEnabled: this._settings?.vncEnabled ?? false,
|
|
397
|
+
vncHost: this._settings?.vncEnabled ? (this._settings?.vncHost || undefined) : undefined,
|
|
398
|
+
vncPort: this._settings?.vncEnabled ? (this._settings?.vncPort ?? 5900) : undefined,
|
|
399
|
+
rdpEnabled: this._settings?.rdpEnabled ?? false,
|
|
400
|
+
rdpHost: this._settings?.rdpEnabled ? (this._settings?.rdpHost || undefined) : undefined,
|
|
401
|
+
rdpPort: this._settings?.rdpEnabled ? (this._settings?.rdpPort ?? 3389) : undefined,
|
|
402
|
+
fileBrowserEnabled: this._settings?.enableFileBrowser ?? false,
|
|
403
|
+
gitEnabled: this._settings?.gitEnabled ?? false,
|
|
404
|
+
});
|
|
405
|
+
// Re-sync working state if the WS dropped mid-task
|
|
406
|
+
if (this._currentTask) {
|
|
407
|
+
this._notifyWebhook('task_start', {
|
|
408
|
+
iteration: this._iterations,
|
|
409
|
+
task: { text: this._currentTask },
|
|
410
|
+
workDir: this._workspaceRoot,
|
|
411
|
+
gitRepo: this._gitRepo,
|
|
412
|
+
gitBranch: this._gitBranch,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
// Re-sync idle state if we previously drained the queue — otherwise the
|
|
416
|
+
// server-side `agent_online` handler flips status back to 'active' and
|
|
417
|
+
// the agent looks busy when it isn't.
|
|
418
|
+
if (!this._currentTask && this._idleNotified) {
|
|
419
|
+
this._notifyWebhook('agent_idle', {
|
|
420
|
+
workDir: this._workspaceRoot,
|
|
421
|
+
gitRepo: this._gitRepo,
|
|
422
|
+
gitBranch: this._gitBranch,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
// Pass VNC password so the poller can authenticate incoming vnc_session requests.
|
|
428
|
+
if (this._webhookPoller && settings.vncEnabled && settings.vncPassword) {
|
|
429
|
+
this._webhookPoller.setVncPassword(settings.vncPassword);
|
|
430
|
+
}
|
|
431
|
+
if (this._webhookPoller) {
|
|
432
|
+
this._webhookPoller.setGitEnabled(settings.gitEnabled ?? false);
|
|
433
|
+
// Wake the idle no-task sleep instantly when a WS-pushed task arrives.
|
|
434
|
+
this._webhookPoller.setOnTaskAppend(() => this._wakeIdleSleep());
|
|
435
|
+
this._webhookPoller.setOnCommand((cmd) => this._handleCommand(cmd));
|
|
436
|
+
this._webhookPoller.setOnMcpUpdate((entries) => this._handleMcpUpdate(entries));
|
|
437
|
+
this._webhookPoller.setOnExportRequest((agentId) => void this._handleExportRequest(agentId));
|
|
438
|
+
this._webhookPoller.setOnRestoreRequest((agentId, downloadUrl) => void this._handleRestoreRequest(agentId, downloadUrl));
|
|
439
|
+
this._webhookPoller.setOnExportConfig((exportEnabled, exportDailyBackup, agentId) => this._handleExportConfig(exportEnabled, exportDailyBackup, agentId));
|
|
440
|
+
}
|
|
441
|
+
if (this._discordPoller) {
|
|
442
|
+
this._discordPoller.setOnCommand((cmd) => this._handleCommand(cmd));
|
|
443
|
+
}
|
|
444
|
+
if (this._webhookPoller && settings.rdpEnabled) {
|
|
445
|
+
this._webhookPoller.setRdpSettings({
|
|
446
|
+
host: settings.rdpHost || undefined,
|
|
447
|
+
port: settings.rdpPort ?? 3389,
|
|
448
|
+
username: settings.rdpUsername || undefined,
|
|
449
|
+
password: settings.rdpPassword || undefined,
|
|
450
|
+
domain: settings.rdpDomain || undefined,
|
|
451
|
+
guacWsUrl: settings.rdpGuacWsUrl || undefined,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
const todoPath = settings.todoPath || path.join(root, 'TODO.md');
|
|
455
|
+
// Seed Discord cursor to ignore history before the loop started
|
|
456
|
+
if (this._discordPoller) {
|
|
457
|
+
await this._discordPoller.initialize();
|
|
458
|
+
}
|
|
459
|
+
if (this._emailPoller) {
|
|
460
|
+
try {
|
|
461
|
+
await this._emailPoller.initialize();
|
|
462
|
+
callbacks.log('🔧 Email task poller started — checking inbox every 10s');
|
|
463
|
+
}
|
|
464
|
+
catch (e) {
|
|
465
|
+
callbacks.log(`Email poller init failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
const root = this._workspaceRoot;
|
|
470
|
+
const userMcp = root ? (0, projectMcp_1.loadProjectUserMcp)(root) : {};
|
|
471
|
+
const entry = userMcp['zerolib-email'];
|
|
472
|
+
const env = entry?.env ?? {};
|
|
473
|
+
const reasons = [];
|
|
474
|
+
if (!entry)
|
|
475
|
+
reasons.push('no zerolib-email entry in .mcp.json');
|
|
476
|
+
else {
|
|
477
|
+
if (String(env.AUTODEV_EMAIL_RECEIVE_TASKS).toLowerCase() !== 'true')
|
|
478
|
+
reasons.push('AUTODEV_EMAIL_RECEIVE_TASKS != "true"');
|
|
479
|
+
if (!env.MCP_EMAIL_SERVER_IMAP_HOST)
|
|
480
|
+
reasons.push('IMAP host missing');
|
|
481
|
+
if (!(env.MCP_EMAIL_SERVER_USER_NAME || env.MCP_EMAIL_SERVER_EMAIL_ADDRESS))
|
|
482
|
+
reasons.push('IMAP user/email missing');
|
|
483
|
+
if (!env.MCP_EMAIL_SERVER_PASSWORD)
|
|
484
|
+
reasons.push('IMAP password missing');
|
|
485
|
+
}
|
|
486
|
+
if (reasons.length)
|
|
487
|
+
callbacks.log(`🔧 Email task poller NOT started: ${reasons.join('; ')}`);
|
|
488
|
+
}
|
|
489
|
+
// Connect to Discord Gateway so the bot appears online
|
|
490
|
+
this._discordGateway?.connect();
|
|
491
|
+
// Start WebSocket connection (no-op for HTTP pollers)
|
|
492
|
+
if (this._webhookPoller) {
|
|
493
|
+
this._webhookPoller.start(todoPath, (msg) => callbacks.log(msg), root);
|
|
494
|
+
}
|
|
495
|
+
// Start independent background polling loops — run even while AI is processing a task
|
|
496
|
+
this._startPollers(todoPath);
|
|
497
|
+
callbacks.log(`Task loop starting — TODO: ${todoPath}`);
|
|
498
|
+
this._notifyWebhook('loop_start', {
|
|
499
|
+
provider: settings.provider,
|
|
500
|
+
workDir: root,
|
|
501
|
+
gitRepo: this._gitRepo,
|
|
502
|
+
gitBranch: this._gitBranch,
|
|
503
|
+
});
|
|
504
|
+
this._notifyWebhook('agent_online', {
|
|
505
|
+
hostname: this._hostname,
|
|
506
|
+
workDir: root,
|
|
507
|
+
gitRepo: this._gitRepo,
|
|
508
|
+
gitBranch: this._gitBranch,
|
|
509
|
+
vncEnabled: settings.vncEnabled ?? false,
|
|
510
|
+
vncHost: settings.vncEnabled ? (settings.vncHost || undefined) : undefined,
|
|
511
|
+
vncPort: settings.vncEnabled ? (settings.vncPort ?? 5900) : undefined,
|
|
512
|
+
rdpEnabled: settings.rdpEnabled ?? false,
|
|
513
|
+
rdpHost: settings.rdpEnabled ? (settings.rdpHost || undefined) : undefined,
|
|
514
|
+
rdpPort: settings.rdpEnabled ? (settings.rdpPort ?? 3389) : undefined,
|
|
515
|
+
fileBrowserEnabled: settings.enableFileBrowser ?? false,
|
|
516
|
+
gitEnabled: settings.gitEnabled ?? false,
|
|
517
|
+
});
|
|
518
|
+
this._notifyDiscord('🚀 AutoDev task loop started');
|
|
519
|
+
// Auto-run `cozempic init` for Claude CLI projects so the guard daemon and
|
|
520
|
+
// pruning hooks are wired automatically — the user only needs cozempic on
|
|
521
|
+
// their PATH; no per-project manual step required.
|
|
522
|
+
if (settings.provider === 'claude-cli') {
|
|
523
|
+
this._runCozempicInit(root, callbacks.log.bind(callbacks));
|
|
524
|
+
}
|
|
525
|
+
this._checkDailyBackup(settings.agentId || settings.webhookSlug || undefined);
|
|
526
|
+
try {
|
|
527
|
+
await this._runLoop(todoPath, settings);
|
|
528
|
+
}
|
|
529
|
+
catch (err) {
|
|
530
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
531
|
+
callbacks.log(`Task loop error: ${msg}`);
|
|
532
|
+
}
|
|
533
|
+
const elapsed = Math.round((Date.now() - this._loopStartTime) / 1000);
|
|
534
|
+
this._notifyWebhook('loop_complete', {
|
|
535
|
+
total: this._completedCount + this._failedCount,
|
|
536
|
+
success: this._completedCount,
|
|
537
|
+
failed: this._failedCount,
|
|
538
|
+
elapsed,
|
|
539
|
+
workDir: root,
|
|
540
|
+
gitRepo: this._gitRepo,
|
|
541
|
+
gitBranch: this._gitBranch,
|
|
542
|
+
});
|
|
543
|
+
this._notifyWebhook('agent_offline', {
|
|
544
|
+
total: this._completedCount + this._failedCount,
|
|
545
|
+
success: this._completedCount,
|
|
546
|
+
failed: this._failedCount,
|
|
547
|
+
elapsed,
|
|
548
|
+
workDir: root,
|
|
549
|
+
gitRepo: this._gitRepo,
|
|
550
|
+
gitBranch: this._gitBranch,
|
|
551
|
+
});
|
|
552
|
+
this._notifyDiscord('👋 AutoDev loop ended');
|
|
553
|
+
this._stopPollers();
|
|
554
|
+
this._currentTask = undefined;
|
|
555
|
+
this._webhook = null;
|
|
556
|
+
this._discordPoller = null;
|
|
557
|
+
this._discordGateway?.destroy();
|
|
558
|
+
this._discordGateway = null;
|
|
559
|
+
this._webhookPoller = null;
|
|
560
|
+
if (this._emailPoller) {
|
|
561
|
+
void this._emailPoller.dispose();
|
|
562
|
+
this._emailPoller = null;
|
|
563
|
+
}
|
|
564
|
+
this._setState('idle');
|
|
565
|
+
callbacks.log('Task loop stopped');
|
|
566
|
+
}
|
|
567
|
+
stop() {
|
|
568
|
+
if (this._state !== 'running' && this._state !== 'paused') {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
this._setState('stopping');
|
|
572
|
+
this._retryScheduler.clear();
|
|
573
|
+
this._resumeAt = undefined;
|
|
574
|
+
this._mainProviderBeforeFallback = null;
|
|
575
|
+
this._mainProviderResumeAt = undefined;
|
|
576
|
+
// Unblock _pause() if we're currently suspended
|
|
577
|
+
const r = this._resumeResolve;
|
|
578
|
+
this._resumeResolve = null;
|
|
579
|
+
r?.();
|
|
580
|
+
// Unblock the idle no-task sleep immediately
|
|
581
|
+
const w = this._idleSleepWake;
|
|
582
|
+
this._idleSleepWake = null;
|
|
583
|
+
w?.();
|
|
584
|
+
this._disposeWatcher();
|
|
585
|
+
this._stopPollers();
|
|
586
|
+
// Abort any in-progress task wait immediately
|
|
587
|
+
this._taskCompletionAbort?.();
|
|
588
|
+
this._taskCompletionAbort = null;
|
|
589
|
+
// Notify Pixel Office / webhook immediately — don't wait for the _runLoop
|
|
590
|
+
// finally block which may never fire if the WS disconnects before cleanup.
|
|
591
|
+
this._notifyWebhook('agent_offline', {
|
|
592
|
+
workDir: this._workspaceRoot,
|
|
593
|
+
gitRepo: this._gitRepo,
|
|
594
|
+
gitBranch: this._gitBranch,
|
|
595
|
+
});
|
|
596
|
+
// Send discord goodbye right now (don't wait for cleanup path)
|
|
597
|
+
this._notifyDiscord('⛔ AutoDev loop stopped');
|
|
598
|
+
this._cb?.log('Task loop stop requested…');
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Stop the loop and, once it reaches idle, start it again with the same
|
|
602
|
+
* callbacks. Useful for picking up new MCP server configs etc.
|
|
603
|
+
*/
|
|
604
|
+
async restart() {
|
|
605
|
+
const savedCb = this._cb;
|
|
606
|
+
if (!savedCb) {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (this._state !== 'idle') {
|
|
610
|
+
this.stop();
|
|
611
|
+
const deadline = Date.now() + 15_000;
|
|
612
|
+
while (this._state !== 'idle' && Date.now() < deadline) {
|
|
613
|
+
await new Promise(r => setTimeout(r, 100));
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
await this.start(savedCb);
|
|
617
|
+
}
|
|
618
|
+
/** Handle mcp_update pushed from pixel-office: write .mcp.json, sync all providers, restart loop. */
|
|
619
|
+
_handleMcpUpdate(entries) {
|
|
620
|
+
const root = this._workspaceRoot;
|
|
621
|
+
if (!root)
|
|
622
|
+
return;
|
|
623
|
+
this._cb?.log('🔧 mcp_update received — writing .mcp.json and syncing providers…');
|
|
624
|
+
try {
|
|
625
|
+
// Cast to the shape saveProjectUserMcp expects
|
|
626
|
+
const typed = entries;
|
|
627
|
+
(0, projectMcp_1.saveProjectUserMcp)(root, typed);
|
|
628
|
+
configManager_1.ConfigManager.syncProjectMcpServers(root, (m) => this._cb?.log(m));
|
|
629
|
+
this._cb?.log('✅ MCP config synced to .mcp.json, opencode.json, .vscode/mcp.json — restarting loop…');
|
|
630
|
+
}
|
|
631
|
+
catch (err) {
|
|
632
|
+
this._cb?.log(`⚠️ MCP update failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
void this.restart();
|
|
636
|
+
}
|
|
637
|
+
/** Handle export_request from pixel-office: create backup zip and upload. */
|
|
638
|
+
async _handleExportRequest(agentId) {
|
|
639
|
+
const root = this._workspaceRoot;
|
|
640
|
+
const settings = this._settings;
|
|
641
|
+
if (!root || !settings?.serverBaseUrl || !settings?.serverApiKey) {
|
|
642
|
+
this._cb?.log('⚠️ export_request ignored — serverBaseUrl/serverApiKey not configured');
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
this._cb?.log('📦 export_request received — building backup zip…');
|
|
646
|
+
const tmpPath = path.join(os.tmpdir(), `agent-backup-${agentId}-${Date.now()}.zip`);
|
|
647
|
+
try {
|
|
648
|
+
await (0, agentBackup_1.createAgentBackup)(root, tmpPath);
|
|
649
|
+
this._cb?.log('📤 Uploading backup to pixel-office…');
|
|
650
|
+
const result = await (0, agentBackup_1.uploadAgentBackup)(tmpPath, agentId, settings.serverBaseUrl, settings.serverApiKey);
|
|
651
|
+
this._saveLastBackupTime(root);
|
|
652
|
+
this._cb?.log(`✅ Backup uploaded: ${result.filename}`);
|
|
653
|
+
}
|
|
654
|
+
catch (err) {
|
|
655
|
+
this._cb?.log(`⚠️ Export failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
656
|
+
}
|
|
657
|
+
finally {
|
|
658
|
+
try {
|
|
659
|
+
fs.unlinkSync(tmpPath);
|
|
660
|
+
}
|
|
661
|
+
catch { /* ignore */ }
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/** Handle restore_request from pixel-office: download zip and restore workspace. */
|
|
665
|
+
async _handleRestoreRequest(agentId, downloadUrl) {
|
|
666
|
+
const root = this._workspaceRoot;
|
|
667
|
+
const settings = this._settings;
|
|
668
|
+
if (!root || !settings?.serverApiKey) {
|
|
669
|
+
this._cb?.log('⚠️ restore_request ignored — serverApiKey not configured');
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
this._cb?.log(`🔄 restore_request received — downloading backup from ${downloadUrl}…`);
|
|
673
|
+
const tmpPath = path.join(os.tmpdir(), `agent-restore-${agentId}-${Date.now()}.zip`);
|
|
674
|
+
try {
|
|
675
|
+
await (0, agentBackup_1.downloadAgentBackup)(downloadUrl, tmpPath, settings.serverApiKey);
|
|
676
|
+
this._cb?.log('📂 Restoring workspace from backup…');
|
|
677
|
+
const result = await (0, agentBackup_1.restoreAgentBackup)(tmpPath, root);
|
|
678
|
+
this._cb?.log(`✅ Restored ${result.workspaceFiles} workspace file(s) — restarting loop…`);
|
|
679
|
+
void this.restart();
|
|
680
|
+
}
|
|
681
|
+
catch (err) {
|
|
682
|
+
this._cb?.log(`⚠️ Restore failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
683
|
+
}
|
|
684
|
+
finally {
|
|
685
|
+
try {
|
|
686
|
+
fs.unlinkSync(tmpPath);
|
|
687
|
+
}
|
|
688
|
+
catch { /* ignore */ }
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
/** Handle export_config from pixel-office: persist exportEnabled + exportDailyBackup to settings. */
|
|
692
|
+
_handleExportConfig(exportEnabled, exportDailyBackup, agentId) {
|
|
693
|
+
const root = this._workspaceRoot;
|
|
694
|
+
if (!root)
|
|
695
|
+
return;
|
|
696
|
+
try {
|
|
697
|
+
const { loadSettingsForRoot: load, settingsWritePath } = require('./core/settingsLoader');
|
|
698
|
+
const current = load(root);
|
|
699
|
+
if (current.exportEnabled === exportEnabled && current.exportDailyBackup === exportDailyBackup && (!agentId || current.agentId === agentId))
|
|
700
|
+
return;
|
|
701
|
+
current.exportEnabled = exportEnabled;
|
|
702
|
+
current.exportDailyBackup = exportDailyBackup;
|
|
703
|
+
if (agentId)
|
|
704
|
+
current.agentId = agentId;
|
|
705
|
+
fs.mkdirSync(path.join(root, '.autodev'), { recursive: true });
|
|
706
|
+
fs.writeFileSync(settingsWritePath(root), JSON.stringify(current, null, 2), 'utf8');
|
|
707
|
+
this._cb?.log(`⚙️ Export config updated: enabled=${exportEnabled}, dailyBackup=${exportDailyBackup}`);
|
|
708
|
+
}
|
|
709
|
+
catch (err) {
|
|
710
|
+
this._cb?.log(`⚠️ export_config write failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
/** Check if a daily backup is due and trigger it automatically. */
|
|
714
|
+
_checkDailyBackup(agentId) {
|
|
715
|
+
if (!agentId)
|
|
716
|
+
return;
|
|
717
|
+
const settings = this._settings;
|
|
718
|
+
if (!settings?.exportEnabled || !settings?.exportDailyBackup)
|
|
719
|
+
return;
|
|
720
|
+
const root = this._workspaceRoot;
|
|
721
|
+
if (!root)
|
|
722
|
+
return;
|
|
723
|
+
const MS_PER_DAY = 86_400_000;
|
|
724
|
+
const statePath = path.join(root, '.autodev', 'last_backup.json');
|
|
725
|
+
try {
|
|
726
|
+
if (fs.existsSync(statePath)) {
|
|
727
|
+
const { ts } = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
728
|
+
if (Date.now() - ts < MS_PER_DAY)
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
catch { /* proceed */ }
|
|
733
|
+
this._cb?.log('⏰ Daily backup due — triggering automatic export…');
|
|
734
|
+
void this._handleExportRequest(agentId);
|
|
735
|
+
}
|
|
736
|
+
_saveLastBackupTime(root) {
|
|
737
|
+
try {
|
|
738
|
+
fs.writeFileSync(path.join(root, '.autodev', 'last_backup.json'), JSON.stringify({ ts: Date.now() }), 'utf8');
|
|
739
|
+
}
|
|
740
|
+
catch { /* ignore */ }
|
|
741
|
+
}
|
|
742
|
+
/** Dispatch a slash command received from any inbound channel. */
|
|
743
|
+
_handleCommand(cmd) {
|
|
744
|
+
const c = cmd.trim().toLowerCase();
|
|
745
|
+
if (c === '/restart') {
|
|
746
|
+
this._cb?.log('🔄 /restart received — restarting loop…');
|
|
747
|
+
this._notifyDiscord('🔄 Restarting loop (/restart received)');
|
|
748
|
+
void this.restart();
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (c === '/clear') {
|
|
752
|
+
this._cb?.log('🗑 /clear received — clearing Claude session…');
|
|
753
|
+
this._notifyDiscord('🗑 `/clear` received — clearing Claude session');
|
|
754
|
+
const root = this._workspaceRoot;
|
|
755
|
+
const provider = this._cb?.getActiveProvider() ?? '';
|
|
756
|
+
if (root) {
|
|
757
|
+
if (provider === 'claude-tui') {
|
|
758
|
+
let sessionId = (0, sessionState_1.getSessionId)(root, 'claude-tui');
|
|
759
|
+
if (!sessionId) {
|
|
760
|
+
sessionId = (0, claudeTuiProvider_1.getClaudeTuiLatestSessionId)(root);
|
|
761
|
+
}
|
|
762
|
+
if (sessionId) {
|
|
763
|
+
(0, claudeTuiProvider_1.runClaudeTuiClear)(root, sessionId, msg => this._cb?.log(msg))
|
|
764
|
+
.then(() => { (0, sessionState_1.clearSessionId)(root, 'claude-tui'); this._cb?.log('🗑 /clear (TUI) complete'); })
|
|
765
|
+
.catch(e => this._cb?.log(`⚠️ /clear (TUI) failed: ${e instanceof Error ? e.message : String(e)}`));
|
|
766
|
+
}
|
|
767
|
+
else {
|
|
768
|
+
this._cb?.log('⚠️ /clear: no active Claude TUI session found');
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
// Default to claude-cli
|
|
773
|
+
let sessionId = (0, sessionState_1.getSessionId)(root, 'claude-cli');
|
|
774
|
+
if (!sessionId) {
|
|
775
|
+
sessionId = (0, dispatcher_1.findLatestClaudeSession)(root);
|
|
776
|
+
}
|
|
777
|
+
if (sessionId) {
|
|
778
|
+
(0, claudeCliProvider_1.runClaudeClear)(sessionId, root, msg => this._cb?.log(msg))
|
|
779
|
+
.then(() => { (0, sessionState_1.clearSessionId)(root, 'claude-cli'); this._cb?.log('🗑 /clear complete'); })
|
|
780
|
+
.catch(e => this._cb?.log(`⚠️ /clear failed: ${e instanceof Error ? e.message : String(e)}`));
|
|
781
|
+
}
|
|
782
|
+
else {
|
|
783
|
+
this._cb?.log('⚠️ /clear: no active Claude session found');
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
// -------------------------------------------------------------------------
|
|
790
|
+
/**
|
|
791
|
+
* Build an EmailTaskPoller from the Email MCP entry's env block, or return
|
|
792
|
+
* null if the feature is disabled or required IMAP creds are missing.
|
|
793
|
+
*/
|
|
794
|
+
_buildEmailPoller(settings) {
|
|
795
|
+
const root = this._workspaceRoot;
|
|
796
|
+
const userMcp = root ? (0, projectMcp_1.loadProjectUserMcp)(root) : {};
|
|
797
|
+
const entry = userMcp['zerolib-email'];
|
|
798
|
+
const env = entry?.env ?? {};
|
|
799
|
+
if (!entry)
|
|
800
|
+
return null;
|
|
801
|
+
if (String(env.AUTODEV_EMAIL_RECEIVE_TASKS).toLowerCase() !== 'true')
|
|
802
|
+
return null;
|
|
803
|
+
const host = env.MCP_EMAIL_SERVER_IMAP_HOST;
|
|
804
|
+
const user = env.MCP_EMAIL_SERVER_USER_NAME || env.MCP_EMAIL_SERVER_EMAIL_ADDRESS;
|
|
805
|
+
const pass = env.MCP_EMAIL_SERVER_PASSWORD;
|
|
806
|
+
if (!host || !user || !pass)
|
|
807
|
+
return null;
|
|
808
|
+
const port = parseInt(env.MCP_EMAIL_SERVER_IMAP_PORT || '993', 10) || 993;
|
|
809
|
+
const secure = String(env.MCP_EMAIL_SERVER_IMAP_SSL ?? 'true').toLowerCase() !== 'false';
|
|
810
|
+
const verify = String(env.MCP_EMAIL_SERVER_IMAP_VERIFY_SSL ?? 'true').toLowerCase() !== 'false';
|
|
811
|
+
const allowed = (env.AUTODEV_EMAIL_ALLOWED_SENDERS || '')
|
|
812
|
+
.split(',').map(s => s.trim()).filter(Boolean);
|
|
813
|
+
return new emailPoller_1.EmailTaskPoller({ host, port, secure, user, pass, allowedSenders: allowed, rejectUnauthorized: verify });
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Start Discord and webhook server pollers as independent setInterval loops.
|
|
817
|
+
* They run continuously in the background — even while the AI is processing a task.
|
|
818
|
+
*/
|
|
819
|
+
_startPollers(todoPath) {
|
|
820
|
+
const POLL_MS = 3_000;
|
|
821
|
+
if (this._discordPoller) {
|
|
822
|
+
const discordInterval = setInterval(async () => {
|
|
823
|
+
if (this._state !== 'running') {
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
try {
|
|
827
|
+
const appended = await this._discordPoller.pollAndAppend(todoPath, this._workspaceRoot ?? undefined);
|
|
828
|
+
if (appended) {
|
|
829
|
+
this._wakeIdleSleep();
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
catch { }
|
|
833
|
+
}, POLL_MS);
|
|
834
|
+
this._pollerIntervals.push(discordInterval);
|
|
835
|
+
}
|
|
836
|
+
if (this._webhookPoller) {
|
|
837
|
+
const webhookInterval = setInterval(async () => {
|
|
838
|
+
if (this._state !== 'running') {
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
try {
|
|
842
|
+
const appended = await this._webhookPoller.pollAndAppend(todoPath, this._workspaceRoot ?? undefined);
|
|
843
|
+
if (appended) {
|
|
844
|
+
this._wakeIdleSleep();
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
catch { }
|
|
848
|
+
}, POLL_MS);
|
|
849
|
+
this._pollerIntervals.push(webhookInterval);
|
|
850
|
+
}
|
|
851
|
+
if (this._emailPoller) {
|
|
852
|
+
// IMAP servers throttle aggressive polling — every 10s is plenty.
|
|
853
|
+
const emailInterval = setInterval(async () => {
|
|
854
|
+
if (this._state !== 'running') {
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
try {
|
|
858
|
+
const appended = await this._emailPoller.pollAndAppend(todoPath, this._workspaceRoot ?? undefined);
|
|
859
|
+
if (appended) {
|
|
860
|
+
this._wakeIdleSleep();
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
catch { }
|
|
864
|
+
}, 10_000);
|
|
865
|
+
this._pollerIntervals.push(emailInterval);
|
|
866
|
+
}
|
|
867
|
+
// Poll <workspace>/.autodev/hooks-events.jsonl every 10s and forward new
|
|
868
|
+
// lines via WS. Per-workspace, NOT homedir: two VS Code instances on the
|
|
869
|
+
// same machine would otherwise both poll one shared file and each ship
|
|
870
|
+
// every line under their own slug — making hooks from `tester-1` show
|
|
871
|
+
// up as `A1` (and vice-versa) in pixel-office.
|
|
872
|
+
if (this._webhookPoller?.isWebSocket && this._workspaceRoot) {
|
|
873
|
+
const hooksJsonl = path.join(this._workspaceRoot, '.autodev', 'hooks-events.jsonl');
|
|
874
|
+
// Start at current file size so we don't replay old events on loop restart
|
|
875
|
+
try {
|
|
876
|
+
this._hooksFileOffset = fs.existsSync(hooksJsonl)
|
|
877
|
+
? fs.statSync(hooksJsonl).size
|
|
878
|
+
: 0;
|
|
879
|
+
}
|
|
880
|
+
catch {
|
|
881
|
+
this._hooksFileOffset = 0;
|
|
882
|
+
}
|
|
883
|
+
// Dedupe window: any hook line byte-identical to one forwarded within
|
|
884
|
+
// this many ms is dropped. Even with per-workspace sinks, parallel
|
|
885
|
+
// copilot/claude processes inside the same workspace can write the
|
|
886
|
+
// same payload several times in one second.
|
|
887
|
+
const HOOKS_DEDUPE_WINDOW_MS = 30_000;
|
|
888
|
+
const hooksInterval = setInterval(() => {
|
|
889
|
+
if (this._state !== 'running') {
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
try {
|
|
893
|
+
if (!fs.existsSync(hooksJsonl)) {
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
const size = fs.statSync(hooksJsonl).size;
|
|
897
|
+
if (size <= this._hooksFileOffset) {
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
const fd = fs.openSync(hooksJsonl, 'r');
|
|
901
|
+
const buf = Buffer.alloc(size - this._hooksFileOffset);
|
|
902
|
+
fs.readSync(fd, buf, 0, buf.length, this._hooksFileOffset);
|
|
903
|
+
fs.closeSync(fd);
|
|
904
|
+
this._hooksFileOffset = size;
|
|
905
|
+
const sessionName = (this._settings?.sessionName && this._settings.sessionName.trim())
|
|
906
|
+
|| (this._workspaceRoot ? path.basename(this._workspaceRoot) : undefined);
|
|
907
|
+
const lines = buf.toString('utf8').split('\n').filter(l => l.trim());
|
|
908
|
+
// Prune dedupe map of stale entries before this tick's run
|
|
909
|
+
const now = Date.now();
|
|
910
|
+
for (const [hash, ts] of this._hookLineSeen) {
|
|
911
|
+
if (now - ts > HOOKS_DEDUPE_WINDOW_MS) {
|
|
912
|
+
this._hookLineSeen.delete(hash);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
for (const line of lines) {
|
|
916
|
+
const hash = crypto.createHash('sha1').update(line).digest('hex');
|
|
917
|
+
const seenAt = this._hookLineSeen.get(hash);
|
|
918
|
+
if (seenAt !== undefined && now - seenAt <= HOOKS_DEDUPE_WINDOW_MS) {
|
|
919
|
+
// Byte-identical hook within the window — drop silently. Distinct
|
|
920
|
+
// tool invocations have at least one differing byte (timestamp,
|
|
921
|
+
// tool input args, runId) and survive this check.
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
this._hookLineSeen.set(hash, now);
|
|
925
|
+
try {
|
|
926
|
+
const ev = JSON.parse(line);
|
|
927
|
+
// Inject session name (workspace folder) so pixel office can display it
|
|
928
|
+
if (sessionName) {
|
|
929
|
+
ev._session_name = sessionName;
|
|
930
|
+
}
|
|
931
|
+
this._webhookPoller.sendFrame({ type: 'hook_event', data: ev });
|
|
932
|
+
}
|
|
933
|
+
catch { /* skip malformed lines */ }
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
catch { /* ignore read errors */ }
|
|
937
|
+
}, 10_000);
|
|
938
|
+
this._pollerIntervals.push(hooksInterval);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Run `cozempic init` in the given project directory if:
|
|
943
|
+
* 1. `cozempic` is on the PATH (or login-shell PATH on Unix)
|
|
944
|
+
* 2. The project hasn't been initialised yet
|
|
945
|
+
* (`.claude/settings.local.json` does not contain a cozempic hook entry)
|
|
946
|
+
*
|
|
947
|
+
* Runs synchronously in a background thread-pool task (spawnSync) so it
|
|
948
|
+
* doesn't block the VS Code event loop but still logs completion.
|
|
949
|
+
*/
|
|
950
|
+
_runCozempicInit(workspaceRoot, log) {
|
|
951
|
+
try {
|
|
952
|
+
// Detect whether cozempic hooks are already wired for this project.
|
|
953
|
+
// `cozempic init` writes its hooks into .claude/settings.local.json.
|
|
954
|
+
const localSettingsPath = path.join(workspaceRoot, '.claude', 'settings.local.json');
|
|
955
|
+
if (fs.existsSync(localSettingsPath)) {
|
|
956
|
+
const content = fs.readFileSync(localSettingsPath, 'utf8');
|
|
957
|
+
if (content.includes('cozempic')) {
|
|
958
|
+
// Already initialised — skip silently.
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
// Resolve cozempic binary (VS Code's process.env.PATH may not include
|
|
963
|
+
// ~/.local/bin where pipx/pip installs it, so try a login shell on Unix).
|
|
964
|
+
const isWin = process.platform === 'win32';
|
|
965
|
+
// Disable telemetry and auto-update pings for all cozempic invocations.
|
|
966
|
+
const cozempicEnv = { ...process.env, COZEMPIC_NO_TELEMETRY: '1', COZEMPIC_NO_AUTO_UPDATE: '1' };
|
|
967
|
+
let cozempicAvailable = false;
|
|
968
|
+
try {
|
|
969
|
+
if (isWin) {
|
|
970
|
+
(0, child_process_1.execSync)('cozempic --version', { stdio: 'pipe', cwd: workspaceRoot, env: cozempicEnv });
|
|
971
|
+
}
|
|
972
|
+
else {
|
|
973
|
+
const shell = process.env.SHELL || 'bash';
|
|
974
|
+
(0, child_process_1.execSync)(`${shell} -lc "cozempic --version"`, { stdio: 'pipe', cwd: workspaceRoot, env: cozempicEnv });
|
|
975
|
+
}
|
|
976
|
+
cozempicAvailable = true;
|
|
977
|
+
}
|
|
978
|
+
catch { /* not installed — skip */ }
|
|
979
|
+
if (!cozempicAvailable) {
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
log('🧹 Running cozempic init for this project…');
|
|
983
|
+
if (isWin) {
|
|
984
|
+
(0, child_process_1.execSync)('cozempic init', { stdio: 'pipe', cwd: workspaceRoot, env: cozempicEnv });
|
|
985
|
+
}
|
|
986
|
+
else {
|
|
987
|
+
const shell = process.env.SHELL || 'bash';
|
|
988
|
+
(0, child_process_1.execSync)(`${shell} -lc "cozempic init"`, { stdio: 'pipe', cwd: workspaceRoot, env: cozempicEnv });
|
|
989
|
+
}
|
|
990
|
+
log('🧹 cozempic init complete — guard daemon and pruning hooks wired');
|
|
991
|
+
}
|
|
992
|
+
catch (err) {
|
|
993
|
+
// Non-fatal — cozempic is optional.
|
|
994
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
995
|
+
log(`⚠️ cozempic init failed (non-fatal): ${msg}`);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
_stopPollers() {
|
|
999
|
+
for (const id of this._pollerIntervals) {
|
|
1000
|
+
clearInterval(id);
|
|
1001
|
+
}
|
|
1002
|
+
this._pollerIntervals = [];
|
|
1003
|
+
// Tear down any persistent WebSocket connection
|
|
1004
|
+
if (this._webhookPoller) {
|
|
1005
|
+
this._webhookPoller.destroy();
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
async _runLoop(todoPath, settings) {
|
|
1009
|
+
let allTasksDoneNotified = false;
|
|
1010
|
+
// Reset any [~] in-progress tasks left over from a previous run
|
|
1011
|
+
if (settings.autoResetPendingTasks) {
|
|
1012
|
+
await todoWriteManager_1.todoWriter.resetAllInProgress(todoPath);
|
|
1013
|
+
this._cb?.log('Auto-reset in-progress tasks to [ ]');
|
|
1014
|
+
}
|
|
1015
|
+
while (this._state === 'running') {
|
|
1016
|
+
// --- Restore main provider after fallback period ends ---
|
|
1017
|
+
if (this._mainProviderBeforeFallback && this._mainProviderResumeAt &&
|
|
1018
|
+
Date.now() >= this._mainProviderResumeAt.getTime()) {
|
|
1019
|
+
const main = this._mainProviderBeforeFallback;
|
|
1020
|
+
this._mainProviderBeforeFallback = null;
|
|
1021
|
+
this._mainProviderResumeAt = undefined;
|
|
1022
|
+
this._resumeAt = undefined;
|
|
1023
|
+
this._cb?.log(`↩ Rate limit period ended — switching back to ${main}`);
|
|
1024
|
+
this._notifyDiscord(`↩ Rate limit period ended — switching back to **${main}**`);
|
|
1025
|
+
this._cb?.setActiveProvider?.(main);
|
|
1026
|
+
}
|
|
1027
|
+
const tasks = (0, todo_1.parseTodo)(todoPath);
|
|
1028
|
+
let task = (0, todo_1.pickNextTask)(tasks); // first [ ] task
|
|
1029
|
+
// Helper: is the CLI process still running? (exit file absent or empty)
|
|
1030
|
+
// On the very first dispatch (_iterations === 0) no process has been launched yet,
|
|
1031
|
+
// so always return false regardless of file state.
|
|
1032
|
+
const provider = this._cb?.getActiveProvider();
|
|
1033
|
+
const cliIsRunning = (() => {
|
|
1034
|
+
if (this._iterations === 0) {
|
|
1035
|
+
return false;
|
|
1036
|
+
} // nothing launched yet
|
|
1037
|
+
if (!this._workspaceRoot || !provider || !providers_1.PROVIDERS[provider]?.isCli) {
|
|
1038
|
+
return false;
|
|
1039
|
+
}
|
|
1040
|
+
// claude-tui: the exit file is per-message and gets a sentinel when the
|
|
1041
|
+
// turn takes >30 s to finish. Instead, use the in-flight turn flag which
|
|
1042
|
+
// stays true for the entire duration of the async, including after
|
|
1043
|
+
// _waitForTaskCompletion resolves but before the turn emits 'result'.
|
|
1044
|
+
if (provider === 'claude-tui') {
|
|
1045
|
+
return (0, claudeTuiProvider_1.isClaudeTuiBusy)(this._workspaceRoot);
|
|
1046
|
+
}
|
|
1047
|
+
if (provider === 'copilot-sdk') {
|
|
1048
|
+
return (0, copilotSdkProvider_1.isCopilotSdkBusy)(this._workspaceRoot);
|
|
1049
|
+
}
|
|
1050
|
+
if (provider === 'opencode-sdk') {
|
|
1051
|
+
return (0, opencodeSdkProvider_1.isOpencodeSdkBusy)(this._workspaceRoot);
|
|
1052
|
+
}
|
|
1053
|
+
// opencode-cli may run on a remote machine (not launched by this extension).
|
|
1054
|
+
// The JSONL hooks file is the authoritative source of truth: if the last
|
|
1055
|
+
// event is a terminal Stop/StopFailure/SessionEnd (or the file is stale),
|
|
1056
|
+
// treat the process as NOT running — regardless of the exit file.
|
|
1057
|
+
// This prevents the exit-file `catch { return true }` fallback from
|
|
1058
|
+
// incorrectly reporting "still running" when the file simply doesn't exist
|
|
1059
|
+
// (remote session never wrote one), causing [~] tasks to be stuck forever.
|
|
1060
|
+
if (provider === 'opencode-cli') {
|
|
1061
|
+
// If the exit file from the last dispatch is non-empty, the process has
|
|
1062
|
+
// definitely exited — don't block waiting for the hooks-file staleness
|
|
1063
|
+
// window. The hooks Stop event may lag behind the bash echo due to async
|
|
1064
|
+
// I/O ordering inside the opencode process, causing a race where
|
|
1065
|
+
// isOpenCodeCliActive() returns true even after the process has exited.
|
|
1066
|
+
// Checking the exit file first avoids a ~90 s stall between tasks.
|
|
1067
|
+
try {
|
|
1068
|
+
const xfContent = fs.readFileSync((0, sessionState_1.exitFilePath)(this._workspaceRoot, provider), 'utf8').trim();
|
|
1069
|
+
if (xfContent !== '') {
|
|
1070
|
+
return false;
|
|
1071
|
+
} // process wrote exit code → done
|
|
1072
|
+
}
|
|
1073
|
+
catch { /* no exit file yet — fall through to hooks check */ }
|
|
1074
|
+
const ocSid = (0, sessionState_1.getSessionId)(this._workspaceRoot, 'opencode-cli');
|
|
1075
|
+
// 5-minute staleness window (was 90 s). The exit-file check above is the
|
|
1076
|
+
// authoritative "done" signal for locally-launched runs, so this only
|
|
1077
|
+
// bridges gaps DURING an active run — e.g. a long LLM generation phase
|
|
1078
|
+
// between tool.execute events. A 90 s window flipped the agent to IDLE
|
|
1079
|
+
// mid-run (and tripped the premature-completion watchdog) whenever a
|
|
1080
|
+
// single turn ran longer than that without emitting an intermediate hook.
|
|
1081
|
+
return (0, openCodeHooksManager_1.isOpenCodeCliActive)(this._workspaceRoot, 300_000, ocSid);
|
|
1082
|
+
}
|
|
1083
|
+
try {
|
|
1084
|
+
const content = fs.readFileSync((0, sessionState_1.exitFilePath)(this._workspaceRoot, provider), 'utf8').trim();
|
|
1085
|
+
return content === ''; // empty = process still running (exit code not yet written)
|
|
1086
|
+
}
|
|
1087
|
+
catch {
|
|
1088
|
+
return true;
|
|
1089
|
+
} // file absent = still running
|
|
1090
|
+
})();
|
|
1091
|
+
// If no [ ] task but CLI is still running and there's a [~] task in flight,
|
|
1092
|
+
// treat that [~] task as the current one and wait — don't interrupt the process.
|
|
1093
|
+
let watchingInProgress = false;
|
|
1094
|
+
if (!task && cliIsRunning) {
|
|
1095
|
+
const inProgress = tasks.find(t => t.status === 'in-progress');
|
|
1096
|
+
if (inProgress) {
|
|
1097
|
+
task = inProgress;
|
|
1098
|
+
watchingInProgress = true;
|
|
1099
|
+
this._cb?.log(`⏳ CLI running, watching in-progress: ${discordLabel(task.text)}`);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
if (!task) {
|
|
1103
|
+
const remaining = (0, todo_1.countRemaining)(tasks);
|
|
1104
|
+
if (remaining === 0) {
|
|
1105
|
+
if (!allTasksDoneNotified) {
|
|
1106
|
+
allTasksDoneNotified = true;
|
|
1107
|
+
this._idleNotified = true;
|
|
1108
|
+
this._cb?.log('All tasks completed ✓ — polling for new tasks…');
|
|
1109
|
+
this._notifyWebhook('all_tasks_done', {
|
|
1110
|
+
workDir: this._workspaceRoot,
|
|
1111
|
+
gitRepo: this._gitRepo,
|
|
1112
|
+
gitBranch: this._gitBranch,
|
|
1113
|
+
});
|
|
1114
|
+
// Explicit idle state — server flips badge to 'idle' on this signal.
|
|
1115
|
+
this._notifyWebhook('agent_idle', {
|
|
1116
|
+
workDir: this._workspaceRoot,
|
|
1117
|
+
gitRepo: this._gitRepo,
|
|
1118
|
+
gitBranch: this._gitBranch,
|
|
1119
|
+
});
|
|
1120
|
+
this._notifyDiscord('✅ All tasks done — waiting for more…');
|
|
1121
|
+
// Notify once-mode callers (e.g. `autodev start --once`) so they can
|
|
1122
|
+
// stop instead of idle-polling forever.
|
|
1123
|
+
this._cb?.onAllTasksDone?.();
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
else {
|
|
1127
|
+
// There are uncompleted tasks but none are pending (e.g. all [~] in-progress).
|
|
1128
|
+
// If the CLI isn't running, those [~] tasks are stranded — the AI
|
|
1129
|
+
// marked them in-progress but never came back to finish them. Reset
|
|
1130
|
+
// them to [ ] so the next iteration picks them up instead of idling
|
|
1131
|
+
// forever.
|
|
1132
|
+
//
|
|
1133
|
+
// Exception: if opencode-cli exited cleanly (Stop event present), the
|
|
1134
|
+
// [~] task was intentionally left in-progress while waiting for an
|
|
1135
|
+
// external response (e.g. waiting for an email from another agent).
|
|
1136
|
+
// In that case we must NOT reset — just wait; the email/webhook
|
|
1137
|
+
// pollers will add new [ ] tasks when the response arrives, which
|
|
1138
|
+
// will trigger a fresh opencode dispatch that also resolves the [~].
|
|
1139
|
+
if (!cliIsRunning) {
|
|
1140
|
+
const cleanExit = provider === 'opencode-cli' &&
|
|
1141
|
+
(0, openCodeHooksManager_1.openCodeExitedCleanly)(this._workspaceRoot ?? '', (0, sessionState_1.getSessionId)(this._workspaceRoot ?? '', 'opencode-cli'));
|
|
1142
|
+
if (cleanExit) {
|
|
1143
|
+
this._cb?.log(`⏳ opencode-cli exited cleanly with [~] task — waiting for external response…`);
|
|
1144
|
+
}
|
|
1145
|
+
else {
|
|
1146
|
+
const stranded = tasks.filter(t => t.status === 'in-progress');
|
|
1147
|
+
for (const t of stranded) {
|
|
1148
|
+
// Auto-mark as [x] done rather than resetting to [ ].
|
|
1149
|
+
// Resetting to [ ] causes an infinite loop: the agent picks it
|
|
1150
|
+
// up, marks [~] again, exits, and we end up here forever.
|
|
1151
|
+
// If the CLI exited leaving a [~] task it means the agent
|
|
1152
|
+
// already worked on it — treat it as done.
|
|
1153
|
+
await todoWriteManager_1.todoWriter.markDone(todoPath, t).catch(() => { });
|
|
1154
|
+
}
|
|
1155
|
+
if (stranded.length > 0) {
|
|
1156
|
+
this._cb?.log(`✅ Auto-marked ${stranded.length} stranded [~] task(s) as done — CLI exited without completing them`);
|
|
1157
|
+
continue; // re-pick immediately
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
this._cb?.log(`No pending tasks — waiting ${settings.loopInterval}s…`);
|
|
1162
|
+
}
|
|
1163
|
+
// Clear any stale current-task label and refresh the sidebar so the
|
|
1164
|
+
// counter reflects the just-finalised TODO.md (otherwise it can sit on
|
|
1165
|
+
// the pre-final-cycle "N left" until something else triggers a push).
|
|
1166
|
+
this._currentTask = undefined;
|
|
1167
|
+
this._setState('running');
|
|
1168
|
+
// Keep polling forever — never stop automatically.
|
|
1169
|
+
// _sleepOrWake() resolves early if a poller appends a task mid-sleep.
|
|
1170
|
+
await this._sleepOrWake(settings.loopInterval * 1000);
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1173
|
+
// A task is available — reset the all-done flag
|
|
1174
|
+
allTasksDoneNotified = false;
|
|
1175
|
+
if (!watchingInProgress) {
|
|
1176
|
+
this._iterations++;
|
|
1177
|
+
// Track how many times we've dispatched this specific task.
|
|
1178
|
+
// If the agent hasn't marked it done after 3 attempts, force-mark it
|
|
1179
|
+
// ourselves and move on so the queue doesn't get stuck.
|
|
1180
|
+
const taskKey = task.id ?? task.text;
|
|
1181
|
+
const attempts = (this._taskAttempts.get(taskKey) ?? 0) + 1;
|
|
1182
|
+
this._taskAttempts.set(taskKey, attempts);
|
|
1183
|
+
const maxAttempts = settings.maxTaskAttempts ?? 3;
|
|
1184
|
+
if (attempts > maxAttempts) {
|
|
1185
|
+
this._taskAttempts.delete(taskKey);
|
|
1186
|
+
this._cb?.log(`⚠️ Task not marked done after ${maxAttempts} attempt(s) — force-marking complete: ${task.text}`);
|
|
1187
|
+
this._notifyDiscord(`⚠️ Task force-marked done after ${maxAttempts} failed attempt(s):\n${task.text}`);
|
|
1188
|
+
await todoWriteManager_1.todoWriter.markDone(todoPath, task).catch(() => { });
|
|
1189
|
+
this._completedCount++;
|
|
1190
|
+
continue;
|
|
1191
|
+
}
|
|
1192
|
+
if (attempts > 1) {
|
|
1193
|
+
this._cb?.log(`🔍 Retrying task (attempt ${attempts}/${maxAttempts}): ${task.text}`);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
// Detect first pickup of a watchingInProgress task (OpenCode was already
|
|
1197
|
+
// running when this iteration started). We need to send task_start so
|
|
1198
|
+
// Pixel Office flips from idle → active — but only once per task, not on
|
|
1199
|
+
// every polling iteration while the process is still running.
|
|
1200
|
+
const isNewWatchedTask = watchingInProgress && this._currentTask !== task.text;
|
|
1201
|
+
this._currentTask = task.text;
|
|
1202
|
+
this._setState('running', task.text);
|
|
1203
|
+
// Decide whether to include the full profile in this dispatch.
|
|
1204
|
+
// Always include on first task; also re-include every profileEveryNTasks if set.
|
|
1205
|
+
const profileInterval = settings.profileEveryNTasks ?? 0;
|
|
1206
|
+
const includeProfile = this._iterations === 1
|
|
1207
|
+
|| (profileInterval > 0 && this._profileSentCounter >= profileInterval);
|
|
1208
|
+
if (includeProfile) {
|
|
1209
|
+
this._profileSentCounter = 0;
|
|
1210
|
+
}
|
|
1211
|
+
this._profileSentCounter++;
|
|
1212
|
+
// Build prompt (needed even when not sending, for messageFile path)
|
|
1213
|
+
const { prompt, messageFile } = (0, prompt_1.buildPrompt)(task, this._workspaceRoot, path.dirname(todoPath), includeProfile);
|
|
1214
|
+
const remaining = (0, todo_1.countRemaining)((0, todo_1.parseTodo)(todoPath));
|
|
1215
|
+
if (!watchingInProgress) {
|
|
1216
|
+
this._cb?.log(`▶ Task [${this._iterations}]: ${task.text}`);
|
|
1217
|
+
this._idleNotified = false;
|
|
1218
|
+
this._notifyWebhook('task_start', {
|
|
1219
|
+
iteration: this._iterations,
|
|
1220
|
+
task: { text: task.text },
|
|
1221
|
+
remaining,
|
|
1222
|
+
workDir: this._workspaceRoot,
|
|
1223
|
+
gitRepo: this._gitRepo,
|
|
1224
|
+
gitBranch: this._gitBranch,
|
|
1225
|
+
});
|
|
1226
|
+
this._notifyDiscord(`▶️ **Task started** (${remaining} remaining):\n${discordLabel(task.text)}`);
|
|
1227
|
+
}
|
|
1228
|
+
else if (isNewWatchedTask) {
|
|
1229
|
+
// CLI was already running when this task was first detected — send task_start
|
|
1230
|
+
// so Pixel Office flips from idle → active (it never saw the original start).
|
|
1231
|
+
this._cb?.log(`⏳ Watching in-progress task: ${task.text}`);
|
|
1232
|
+
this._idleNotified = false;
|
|
1233
|
+
this._notifyWebhook('task_start', {
|
|
1234
|
+
iteration: this._iterations,
|
|
1235
|
+
task: { text: task.text },
|
|
1236
|
+
remaining,
|
|
1237
|
+
workDir: this._workspaceRoot,
|
|
1238
|
+
gitRepo: this._gitRepo,
|
|
1239
|
+
gitBranch: this._gitBranch,
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
const taskStartTime = Date.now();
|
|
1243
|
+
// Snapshot the JSONL cursor before sending — we only read bytes written after this
|
|
1244
|
+
const claudeCursor = (0, dispatcher_1.getClaudeSessionCursor)(this._workspaceRoot);
|
|
1245
|
+
try {
|
|
1246
|
+
if (cliIsRunning || watchingInProgress) {
|
|
1247
|
+
this._cb?.log(`⏳ CLI still running — skipping send, waiting for task completion…`);
|
|
1248
|
+
}
|
|
1249
|
+
else {
|
|
1250
|
+
// Send to AI — resolves as soon as the prompt is pasted, not when Claude finishes
|
|
1251
|
+
await this._cb.sendToAi(prompt, task.text, includeProfile, messageFile);
|
|
1252
|
+
}
|
|
1253
|
+
// Wait for the AI to mark the task [x] done in TODO.md
|
|
1254
|
+
await this._waitForTaskCompletion(todoPath, task, claudeCursor);
|
|
1255
|
+
// Wait for the CLI process to fully exit before reading its stdout.
|
|
1256
|
+
// The exit-code file is written only after the shell command completes,
|
|
1257
|
+
// so a non-empty file guarantees the stdout file is fully flushed.
|
|
1258
|
+
const activeProvider = this._cb?.getActiveProvider();
|
|
1259
|
+
if (this._workspaceRoot && activeProvider === 'claude-tui') {
|
|
1260
|
+
// claude-tui: _waitForTaskCompletion resolves as soon as the task is
|
|
1261
|
+
// marked [x] in TODO.md, but the persistent async turn may still be
|
|
1262
|
+
// running (Claude executing further tool calls, marking other tasks [~],
|
|
1263
|
+
// etc.). Wait for the busy flag to clear — written at the very end of
|
|
1264
|
+
// the fire-and-forget async after 'result' fires — so we don't
|
|
1265
|
+
// prematurely proceed while the client is still mid-turn.
|
|
1266
|
+
//
|
|
1267
|
+
// Use activity-based deadline: reset the 10-minute window whenever a
|
|
1268
|
+
// new streaming event arrives so we never cut off an active turn early.
|
|
1269
|
+
// Only time out after 10 consecutive minutes of no streaming activity.
|
|
1270
|
+
const INACTIVITY_MS = 10 * 60_000;
|
|
1271
|
+
let lastSeenActivity = (0, claudeTuiProvider_1.getClaudeTuiLastActivity)(this._workspaceRoot);
|
|
1272
|
+
let tuiDeadline = Date.now() + INACTIVITY_MS;
|
|
1273
|
+
while ((0, claudeTuiProvider_1.isClaudeTuiBusy)(this._workspaceRoot) && Date.now() < tuiDeadline) {
|
|
1274
|
+
if (this._state !== 'running') {
|
|
1275
|
+
break;
|
|
1276
|
+
}
|
|
1277
|
+
await this._sleepAbortable(500);
|
|
1278
|
+
// Reset deadline if new activity arrived since last check.
|
|
1279
|
+
const nowActivity = (0, claudeTuiProvider_1.getClaudeTuiLastActivity)(this._workspaceRoot);
|
|
1280
|
+
if (nowActivity > lastSeenActivity) {
|
|
1281
|
+
lastSeenActivity = nowActivity;
|
|
1282
|
+
tuiDeadline = Date.now() + INACTIVITY_MS;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
if ((0, claudeTuiProvider_1.isClaudeTuiBusy)(this._workspaceRoot)) {
|
|
1286
|
+
this._cb?.log('⚠️ Claude TUI turn: no streaming activity for 10 minutes — force-clearing busy state and moving on');
|
|
1287
|
+
(0, claudeTuiProvider_1.forceIdleClaudeTui)(this._workspaceRoot);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
else if (this._workspaceRoot && activeProvider === 'copilot-sdk') {
|
|
1291
|
+
const tuiDeadline = Date.now() + 10 * 60_000;
|
|
1292
|
+
while ((0, copilotSdkProvider_1.isCopilotSdkBusy)(this._workspaceRoot) && Date.now() < tuiDeadline) {
|
|
1293
|
+
if (this._state !== 'running') {
|
|
1294
|
+
break;
|
|
1295
|
+
}
|
|
1296
|
+
await this._sleepAbortable(500);
|
|
1297
|
+
}
|
|
1298
|
+
if ((0, copilotSdkProvider_1.isCopilotSdkBusy)(this._workspaceRoot)) {
|
|
1299
|
+
this._cb?.log('⚠️ Copilot TUI turn did not complete within 10 minutes — moving on');
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
else if (this._workspaceRoot && activeProvider === 'opencode-sdk') {
|
|
1303
|
+
// opencode-sdk: same pattern as claude-tui — wait for the in-flight
|
|
1304
|
+
// fire-and-forget async to fully complete (session.idle received).
|
|
1305
|
+
// Use activity-based deadline: reset the 30-minute window on each tool
|
|
1306
|
+
// activity change so long-running tasks are never prematurely cut off.
|
|
1307
|
+
const INACTIVITY_MS_SDK = 30 * 60_000;
|
|
1308
|
+
let sdkDeadline = Date.now() + INACTIVITY_MS_SDK;
|
|
1309
|
+
let lastActivity;
|
|
1310
|
+
while ((0, opencodeSdkProvider_1.isOpencodeSdkBusy)(this._workspaceRoot) && Date.now() < sdkDeadline) {
|
|
1311
|
+
if (this._state !== 'running') {
|
|
1312
|
+
break;
|
|
1313
|
+
}
|
|
1314
|
+
// Escape hatch: if hooks-events.jsonl shows server was disposed but
|
|
1315
|
+
// the SDK async never got the event (e.g. stream closed before we
|
|
1316
|
+
// could read it), force-clear the busy flag so we don’t wait forever.
|
|
1317
|
+
const sdkSid = (0, opencodeSdkProvider_1.getOpencodeSdkLatestSessionId)(this._workspaceRoot);
|
|
1318
|
+
if ((0, openCodeHooksManager_1.openCodeExitedCleanly)(this._workspaceRoot, sdkSid)) {
|
|
1319
|
+
this._cb?.log('⚠️ OpenCode SDK: server disposed detected via hooks — force-clearing busy state');
|
|
1320
|
+
(0, opencodeSdkProvider_1.forceIdleOpencodeSdk)(this._workspaceRoot);
|
|
1321
|
+
break;
|
|
1322
|
+
}
|
|
1323
|
+
// Forward tool activity changes to the sidebar; reset inactivity deadline.
|
|
1324
|
+
const act = (0, opencodeSdkProvider_1.getOpencodeSdkActivity)(this._workspaceRoot);
|
|
1325
|
+
if (act !== lastActivity) {
|
|
1326
|
+
lastActivity = act;
|
|
1327
|
+
this._cb?.onActivityChange?.(act);
|
|
1328
|
+
sdkDeadline = Date.now() + INACTIVITY_MS_SDK;
|
|
1329
|
+
}
|
|
1330
|
+
await this._sleepAbortable(500);
|
|
1331
|
+
}
|
|
1332
|
+
if (lastActivity !== undefined) {
|
|
1333
|
+
this._cb?.onActivityChange?.(undefined);
|
|
1334
|
+
}
|
|
1335
|
+
if ((0, opencodeSdkProvider_1.isOpencodeSdkBusy)(this._workspaceRoot)) {
|
|
1336
|
+
this._cb?.log('⚠️ OpenCode SDK turn: no activity for 30 minutes — force-clearing busy state and moving on');
|
|
1337
|
+
(0, opencodeSdkProvider_1.forceIdleOpencodeSdk)(this._workspaceRoot);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
else if (this._workspaceRoot && activeProvider && providers_1.PROVIDERS[activeProvider]?.isCli) {
|
|
1341
|
+
const exitFile = (0, sessionState_1.exitFilePath)(this._workspaceRoot, activeProvider);
|
|
1342
|
+
// Do NOT clear the file here. Each dispatch allocates a fresh
|
|
1343
|
+
// per-message exit file via newMessageOutput(), so the value we see
|
|
1344
|
+
// is the one the CLI just wrote. Clearing it would leave it empty
|
|
1345
|
+
// forever (no CLI is running to re-write it) and the NEXT iteration's
|
|
1346
|
+
// cliIsRunning probe would then incorrectly conclude "CLI still
|
|
1347
|
+
// running" — pinning the loop on a task that was never dispatched.
|
|
1348
|
+
const isReady = () => {
|
|
1349
|
+
try {
|
|
1350
|
+
return fs.readFileSync(exitFile, 'utf8').trim().length > 0;
|
|
1351
|
+
}
|
|
1352
|
+
catch {
|
|
1353
|
+
return false;
|
|
1354
|
+
}
|
|
1355
|
+
};
|
|
1356
|
+
if (!isReady()) {
|
|
1357
|
+
// Poll up to 30 s for the exit file to become non-empty
|
|
1358
|
+
const deadline = Date.now() + 30_000;
|
|
1359
|
+
while (Date.now() < deadline) {
|
|
1360
|
+
await this._sleepAbortable(500);
|
|
1361
|
+
if (this._state !== 'running') {
|
|
1362
|
+
break;
|
|
1363
|
+
}
|
|
1364
|
+
if (isReady()) {
|
|
1365
|
+
break;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
// If still empty after the wait, write a sentinel so the next
|
|
1369
|
+
// iteration's cliIsRunning probe doesn't read an empty file and
|
|
1370
|
+
// wrongly conclude "CLI still running". The shell may have failed
|
|
1371
|
+
// to run the trailing `echo $? > exitFile` (terminal killed,
|
|
1372
|
+
// bundle aborted, etc.) — but the task is done and we're moving on.
|
|
1373
|
+
if (!isReady()) {
|
|
1374
|
+
try {
|
|
1375
|
+
fs.writeFileSync(exitFile, 'unknown\n', 'utf8');
|
|
1376
|
+
}
|
|
1377
|
+
catch { /* ignore */ }
|
|
1378
|
+
this._cb?.log('⚠️ CLI exit file never written — wrote sentinel to unblock next cycle');
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
else {
|
|
1383
|
+
// Non-CLI providers: just wait for OS flush
|
|
1384
|
+
await this._sleepAbortable(2_000);
|
|
1385
|
+
}
|
|
1386
|
+
// Capture and persist CLI session ID so the next task can resume it
|
|
1387
|
+
if (this._workspaceRoot && activeProvider && providers_1.PROVIDERS[activeProvider]?.isCli) {
|
|
1388
|
+
if (activeProvider === 'opencode-cli') {
|
|
1389
|
+
// Prefer the session ID from hooks events (fast, no subprocess);
|
|
1390
|
+
// fall back to `opencode session list` if hooks haven't fired yet.
|
|
1391
|
+
// Pass taskStartTime so we ignore stale/foreign hooks events from
|
|
1392
|
+
// before this dispatch (prevents cross-folder contamination).
|
|
1393
|
+
const hooksSid = (0, openCodeHooksManager_1.getOpenCodeSessionIdFromHooks)(this._workspaceRoot, taskStartTime);
|
|
1394
|
+
if (hooksSid) {
|
|
1395
|
+
(0, sessionState_1.saveSessionId)(this._workspaceRoot, 'opencode-cli', hooksSid);
|
|
1396
|
+
this._cb?.log(`OpenCode session ID from hooks: ${hooksSid}`);
|
|
1397
|
+
}
|
|
1398
|
+
else {
|
|
1399
|
+
(0, opencodeCliProvider_1.getLatestOpenCodeSessionId)(this._workspaceRoot, msg => this._cb?.log(msg))
|
|
1400
|
+
.then(id => { if (id && this._workspaceRoot) {
|
|
1401
|
+
(0, sessionState_1.saveSessionId)(this._workspaceRoot, 'opencode-cli', id);
|
|
1402
|
+
} })
|
|
1403
|
+
.catch(() => { });
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
else if (activeProvider === 'claude-tui') {
|
|
1407
|
+
const sid = (0, claudeTuiProvider_1.getClaudeTuiLatestSessionId)(this._workspaceRoot);
|
|
1408
|
+
if (sid) {
|
|
1409
|
+
(0, sessionState_1.saveSessionId)(this._workspaceRoot, 'claude-tui', sid);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
else if (activeProvider === 'copilot-sdk') {
|
|
1413
|
+
const sid = (0, copilotSdkProvider_1.getLatestCopilotSdkSessionId)(this._workspaceRoot);
|
|
1414
|
+
if (sid) {
|
|
1415
|
+
(0, sessionState_1.saveSessionId)(this._workspaceRoot, 'copilot-sdk', sid);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
else if (activeProvider === 'opencode-sdk') {
|
|
1419
|
+
const sid = (0, opencodeSdkProvider_1.getOpencodeSdkLatestSessionId)(this._workspaceRoot);
|
|
1420
|
+
if (sid) {
|
|
1421
|
+
(0, sessionState_1.saveSessionId)(this._workspaceRoot, 'opencode-sdk', sid);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
else {
|
|
1425
|
+
const jsonlFallback = activeProvider === 'claude-cli'
|
|
1426
|
+
? (0, dispatcher_1.findLatestClaudeSession)(this._workspaceRoot)
|
|
1427
|
+
: undefined;
|
|
1428
|
+
(0, sessionState_1.captureAndSaveSessionId)(this._workspaceRoot, activeProvider, jsonlFallback);
|
|
1429
|
+
// Apply the configured display name to the now-known claude session
|
|
1430
|
+
// (the --resume picker label lives in ~/.claude/history.jsonl).
|
|
1431
|
+
if (activeProvider === 'claude-cli' && jsonlFallback && this._settings?.sessionName) {
|
|
1432
|
+
(0, dispatcher_1.setClaudeSessionName)(jsonlFallback, this._settings.sessionName, this._workspaceRoot);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
this._cb?.log(`Session ID captured for ${activeProvider}`);
|
|
1436
|
+
}
|
|
1437
|
+
const duration = Math.round((Date.now() - taskStartTime) / 1000);
|
|
1438
|
+
this._completedCount++;
|
|
1439
|
+
this._autoCompactCounter++;
|
|
1440
|
+
this._resetSessionCounter++;
|
|
1441
|
+
this._periodicMgr.increment(this._iterations, this._workspaceRoot);
|
|
1442
|
+
this._compactedTaskLines.delete(task.line); // allow compact again if task re-appears
|
|
1443
|
+
this._taskAttempts.delete(task.id ?? task.text); // task done — reset attempt counter
|
|
1444
|
+
const afterTasks = (0, todo_1.parseTodo)(todoPath);
|
|
1445
|
+
const afterRemaining = (0, todo_1.countRemaining)(afterTasks);
|
|
1446
|
+
const totalKnown = this._iterations + afterRemaining;
|
|
1447
|
+
// Read the AI's output — prefer clean JSONL assistant text (no tool noise),
|
|
1448
|
+
// fall back to the tail of the raw stdout file.
|
|
1449
|
+
let taskOutput = '';
|
|
1450
|
+
if (this._workspaceRoot && (activeProvider === 'claude-cli' || activeProvider === 'claude-tui')) {
|
|
1451
|
+
// Primary: clean assistant-only text extracted from the JSONL session file.
|
|
1452
|
+
// Works for both claude-cli and claude-tui — both write the same JSONL format.
|
|
1453
|
+
// For claude-tui this replaces the noisy partial-chunk Discord stream with
|
|
1454
|
+
// one clean summary sent at task completion.
|
|
1455
|
+
taskOutput = (0, dispatcher_2.readClaudeOutputSince)(this._workspaceRoot, claudeCursor);
|
|
1456
|
+
}
|
|
1457
|
+
else if (this._workspaceRoot && activeProvider === 'copilot-sdk') {
|
|
1458
|
+
// copilot-sdk: read from the per-message stdoutFile (absolute path stored
|
|
1459
|
+
// at pointer file location in the .autodev directory).
|
|
1460
|
+
const outFile = (0, sessionState_1.stdoutFilePath)(this._workspaceRoot, 'copilot-sdk');
|
|
1461
|
+
taskOutput = (0, copilotSdkProvider_1.readCopilotSdkOutputSince)(outFile, 0);
|
|
1462
|
+
}
|
|
1463
|
+
if (!taskOutput && this._workspaceRoot && activeProvider && providers_1.PROVIDERS[activeProvider]?.isCli) {
|
|
1464
|
+
// Fallback: raw stdout file — take only the last 4 KB to avoid huge payloads.
|
|
1465
|
+
const outFile = (0, sessionState_1.stdoutFilePath)(this._workspaceRoot, activeProvider);
|
|
1466
|
+
try {
|
|
1467
|
+
if (fs.existsSync(outFile)) {
|
|
1468
|
+
const raw = readOutputFile(outFile);
|
|
1469
|
+
// Strip ANSI escape codes and take the tail (the meaningful summary is at the end)
|
|
1470
|
+
const clean = raw.replace(/\x1B\[[0-9;]*[mGKHF]/g, '').replace(/\r/g, '');
|
|
1471
|
+
taskOutput = clean.length > 4000 ? '…' + clean.slice(-4000) : clean;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
catch { /* ignore */ }
|
|
1475
|
+
}
|
|
1476
|
+
this._cb?.log(`\u2705 Task done: ${task.text}`);
|
|
1477
|
+
this._notifyWebhook('task_done', {
|
|
1478
|
+
iteration: this._iterations,
|
|
1479
|
+
task: { text: task.text },
|
|
1480
|
+
output: taskOutput || undefined,
|
|
1481
|
+
duration,
|
|
1482
|
+
workDir: this._workspaceRoot,
|
|
1483
|
+
gitRepo: this._gitRepo,
|
|
1484
|
+
gitBranch: this._gitBranch,
|
|
1485
|
+
});
|
|
1486
|
+
const discordOutput = taskOutput
|
|
1487
|
+
? `\n\`\`\`\n${taskOutput.slice(0, 1800)}\n\`\`\``
|
|
1488
|
+
: '';
|
|
1489
|
+
this._notifyDiscord(`\u2705 **Task done** (${afterRemaining} remaining):\n${discordLabel(task.text)}${discordOutput}`);
|
|
1490
|
+
if (afterRemaining > 0) {
|
|
1491
|
+
this._notifyDiscord(`\ud83d\udcca Progress: ${this._iterations}/${totalKnown}`);
|
|
1492
|
+
this._notifyWebhook('task_progress', {
|
|
1493
|
+
iteration: this._iterations,
|
|
1494
|
+
total: totalKnown,
|
|
1495
|
+
remaining: afterRemaining,
|
|
1496
|
+
workDir: this._workspaceRoot,
|
|
1497
|
+
gitRepo: this._gitRepo,
|
|
1498
|
+
gitBranch: this._gitBranch,
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
// --- Auto-compact: run /compact every N completed tasks -----------
|
|
1502
|
+
// Skip the legacy autoCompact mechanism when compactEveryNTasks is set —
|
|
1503
|
+
// the new periodic-action system handles it and they must not both fire.
|
|
1504
|
+
const compactInterval = settings.autoCompactInterval ?? 5;
|
|
1505
|
+
if (settings.autoCompact && !(settings.compactEveryNTasks > 0) && this._autoCompactCounter >= compactInterval) {
|
|
1506
|
+
this._autoCompactCounter = 0;
|
|
1507
|
+
const acProvider = this._cb?.getActiveProvider() ?? '';
|
|
1508
|
+
this._cb?.log(`🗜 Auto-compact triggered after ${compactInterval} tasks (provider: ${acProvider})`);
|
|
1509
|
+
this._notifyDiscord(`🗜 Auto-compact triggered after ${compactInterval} tasks`);
|
|
1510
|
+
try {
|
|
1511
|
+
if (acProvider === 'claude-cli') {
|
|
1512
|
+
let sid = (0, sessionState_1.getSessionId)(this._workspaceRoot, 'claude-cli');
|
|
1513
|
+
if (!sid) {
|
|
1514
|
+
sid = (0, dispatcher_1.findLatestClaudeSession)(this._workspaceRoot);
|
|
1515
|
+
}
|
|
1516
|
+
if (sid) {
|
|
1517
|
+
await (0, claudeCliProvider_1.runClaudeCompact)(sid, this._workspaceRoot, msg => this._cb?.log(msg));
|
|
1518
|
+
this._cb?.log('🗜 Auto-compact complete');
|
|
1519
|
+
}
|
|
1520
|
+
else {
|
|
1521
|
+
this._cb?.log('⚠️ Auto-compact: no Claude session ID found — skipping');
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
else if (acProvider === 'claude-tui') {
|
|
1525
|
+
let sid = (0, sessionState_1.getSessionId)(this._workspaceRoot, 'claude-tui');
|
|
1526
|
+
if (!sid) {
|
|
1527
|
+
sid = (0, claudeTuiProvider_1.getClaudeTuiLatestSessionId)(this._workspaceRoot);
|
|
1528
|
+
}
|
|
1529
|
+
if (sid) {
|
|
1530
|
+
await (0, claudeTuiProvider_1.runClaudeTuiCompact)(this._workspaceRoot, sid, msg => this._cb?.log(msg));
|
|
1531
|
+
this._cb?.log('🗜 Auto-compact complete');
|
|
1532
|
+
}
|
|
1533
|
+
else {
|
|
1534
|
+
this._cb?.log('⚠️ Auto-compact: no Claude TUI session ID found — skipping');
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
else if (acProvider === 'copilot-sdk') {
|
|
1538
|
+
this._cb?.log('ℹ️ Auto-compact not supported for copilot-sdk — skipping');
|
|
1539
|
+
}
|
|
1540
|
+
else if (acProvider === 'opencode-cli') {
|
|
1541
|
+
let sid = (0, sessionState_1.getSessionId)(this._workspaceRoot, 'opencode-cli');
|
|
1542
|
+
if (!sid) {
|
|
1543
|
+
sid = await (0, opencodeCliProvider_1.getLatestOpenCodeSessionId)(this._workspaceRoot, msg => this._cb?.log(msg));
|
|
1544
|
+
}
|
|
1545
|
+
if (sid) {
|
|
1546
|
+
await (0, opencodeCliProvider_1.runOpenCodeCompact)(sid, this._workspaceRoot, msg => this._cb?.log(msg));
|
|
1547
|
+
this._cb?.log('🗜 Auto-compact complete');
|
|
1548
|
+
}
|
|
1549
|
+
else {
|
|
1550
|
+
this._cb?.log('⚠️ Auto-compact: no OpenCode session ID found — skipping');
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
else if (acProvider === 'opencode-sdk') {
|
|
1554
|
+
await (0, opencodeSdkProvider_1.runOpencodeSdkCompact)(this._workspaceRoot, msg => this._cb?.log(msg));
|
|
1555
|
+
this._cb?.log('🗜 Auto-compact complete (opencode-sdk)');
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
catch (compactErr) {
|
|
1559
|
+
const cm = compactErr instanceof Error ? compactErr.message : String(compactErr);
|
|
1560
|
+
this._cb?.log(`⚠️ Auto-compact failed (non-fatal): ${cm}`);
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
// --- Session reset: clear session every N completed tasks ---------
|
|
1564
|
+
const resetInterval = settings.resetSessionEveryNTurns ?? 0;
|
|
1565
|
+
if (settings.resumeSession && resetInterval > 0 && this._resetSessionCounter >= resetInterval) {
|
|
1566
|
+
this._resetSessionCounter = 0;
|
|
1567
|
+
const rsProvider = this._cb?.getActiveProvider() ?? '';
|
|
1568
|
+
this._cb?.log(`🔄 Session reset triggered after ${resetInterval} tasks (provider: ${rsProvider})`);
|
|
1569
|
+
this._notifyDiscord(`🔄 Session reset after ${resetInterval} tasks — summarising and starting fresh`);
|
|
1570
|
+
// Ask the agent to write a summary before the session is cleared
|
|
1571
|
+
try {
|
|
1572
|
+
const summaryMsg = `Before this session ends, please summarise everything accomplished so far into a file called SUMMARY.md in the project root. Include: tasks completed, key decisions made, any issues found, and current project state. Write comprehensively so the next session can continue without context loss. Then stop — do not pick up any new tasks.`;
|
|
1573
|
+
const summaryFile = (0, messageBuilder_1.writeMessageFile)(this._workspaceRoot, summaryMsg);
|
|
1574
|
+
await this._cb.sendToAi(summaryMsg, 'session-summary', false, summaryFile);
|
|
1575
|
+
}
|
|
1576
|
+
catch (rsErr) {
|
|
1577
|
+
const rm = rsErr instanceof Error ? rsErr.message : String(rsErr);
|
|
1578
|
+
this._cb?.log(`⚠️ Session reset summary failed (non-fatal): ${rm}`);
|
|
1579
|
+
}
|
|
1580
|
+
// Clear the session ID so the next dispatch starts a fresh session
|
|
1581
|
+
if (this._workspaceRoot && rsProvider) {
|
|
1582
|
+
(0, sessionState_1.clearSessionId)(this._workspaceRoot, rsProvider);
|
|
1583
|
+
this._cb?.log(`🔄 Session ID cleared — next task will start a new session`);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
// --- Periodic actions: compact / pruneTodo / skill / memory / summary / etc. --
|
|
1587
|
+
for (const action of this._periodicMgr.getDue(settings)) {
|
|
1588
|
+
this._periodicMgr.markHandled(action.id, this._iterations, this._workspaceRoot);
|
|
1589
|
+
this._cb?.log(`${action.icon} Periodic action '${action.id}' triggered`);
|
|
1590
|
+
this._notifyDiscord(`${action.icon} Periodic action: ${action.label}`);
|
|
1591
|
+
try {
|
|
1592
|
+
if (action.type === 'compact') {
|
|
1593
|
+
// Guard: time-based throttle — minimum 2 minutes between compacts
|
|
1594
|
+
const MIN_COMPACT_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes
|
|
1595
|
+
const timeSinceLastCompact = Date.now() - this._lastCompactTime;
|
|
1596
|
+
if (timeSinceLastCompact < MIN_COMPACT_INTERVAL_MS) {
|
|
1597
|
+
const waitSec = Math.ceil((MIN_COMPACT_INTERVAL_MS - timeSinceLastCompact) / 1000);
|
|
1598
|
+
this._cb?.log(`⚠️ Auto-compact throttled: wait ${waitSec}s (minimum 2min between compacts)`);
|
|
1599
|
+
}
|
|
1600
|
+
else {
|
|
1601
|
+
const acProvider = this._cb?.getActiveProvider() ?? '';
|
|
1602
|
+
if (acProvider && this._workspaceRoot) {
|
|
1603
|
+
await this.compact(this._workspaceRoot, acProvider);
|
|
1604
|
+
}
|
|
1605
|
+
else {
|
|
1606
|
+
this._cb?.log(`⚠️ Periodic compact: no active provider`);
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
else if (action.type === 'pruneTodo') {
|
|
1611
|
+
if (this._workspaceRoot) {
|
|
1612
|
+
const pruned = (0, todo_1.pruneTodoToArchive)(todoPath, this._workspaceRoot);
|
|
1613
|
+
if (pruned > 0) {
|
|
1614
|
+
this._cb?.log(`🧹 Pruned ${pruned} completed task(s) from TODO.md → DONE.md`);
|
|
1615
|
+
this._notifyDiscord(`🧹 Pruned ${pruned} completed task(s) from TODO.md → DONE.md`);
|
|
1616
|
+
}
|
|
1617
|
+
else {
|
|
1618
|
+
this._cb?.log(`🧹 Prune TODO: no completed tasks to move`);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
else {
|
|
1623
|
+
const msgFile = (0, messageBuilder_1.writeMessageFile)(this._workspaceRoot, action.prompt);
|
|
1624
|
+
await this._cb.sendToAi(action.prompt, action.id, false, msgFile);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
catch (paErr) {
|
|
1628
|
+
const pm = paErr instanceof Error ? paErr.message : String(paErr);
|
|
1629
|
+
this._cb?.log(`⚠️ Periodic action '${action.id}' failed (non-fatal): ${pm}`);
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
catch (err) {
|
|
1634
|
+
// --- Rate limit: pause loop, schedule auto-resume -----------------
|
|
1635
|
+
if (err instanceof rateLimit_1.RateLimitError) {
|
|
1636
|
+
// Two flavours:
|
|
1637
|
+
// 1. Daily usage limit — message includes "resets 9pm (Europe/Sofia)"
|
|
1638
|
+
// → resume 15 min after the parsed reset time.
|
|
1639
|
+
// 2. Transient server throttle — "API Error: Server is temporarily
|
|
1640
|
+
// limiting requests (not your usage limit) · Rate limited"
|
|
1641
|
+
// → no reset time given, retry in 5 minutes by default.
|
|
1642
|
+
const DEFAULT_RETRY_MS = 5 * 60_000;
|
|
1643
|
+
const resetAt = err.resetAt;
|
|
1644
|
+
const resumeMs = resetAt ? (resetAt.getTime() - Date.now() + 15 * 60_000) : DEFAULT_RETRY_MS;
|
|
1645
|
+
const resumeAt = resetAt ?? new Date(Date.now() + DEFAULT_RETRY_MS);
|
|
1646
|
+
const resumeStr = resumeAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
1647
|
+
const suffix = resetAt ? '+15 min' : 'retry in 5m (no reset time given)';
|
|
1648
|
+
const rawMsg = err.rawMessage;
|
|
1649
|
+
const currentProvider = this._cb?.getActiveProvider() ?? 'unknown';
|
|
1650
|
+
// Fresh settings — check fallback config (user may have changed it after loop start)
|
|
1651
|
+
const freshSettings = this._workspaceRoot ? (0, settingsLoader_1.loadSettingsForRoot)(this._workspaceRoot) : this._settings;
|
|
1652
|
+
const fallbackEnabled = freshSettings?.fallbackProviderEnabled ?? false;
|
|
1653
|
+
const fallbackId = (freshSettings?.fallbackProvider ?? '');
|
|
1654
|
+
// Use fallback if: enabled, different from current provider, and not already on fallback
|
|
1655
|
+
if (fallbackEnabled && fallbackId && fallbackId !== currentProvider && !this._mainProviderBeforeFallback) {
|
|
1656
|
+
this._mainProviderBeforeFallback = currentProvider;
|
|
1657
|
+
this._mainProviderResumeAt = resumeAt;
|
|
1658
|
+
this._resumeAt = resumeAt;
|
|
1659
|
+
this._cb?.log(`⏩ Rate limit on ${currentProvider} — switching to ${fallbackId} until ${resumeStr} (${suffix})`);
|
|
1660
|
+
this._notifyDiscord(`⏩ **Rate limit on ${currentProvider}** — switching to **${fallbackId}** until ${resumeStr} (${suffix})\n\`\`\`\n${rawMsg}\n\`\`\``);
|
|
1661
|
+
this._notifyWebhook('rate_limit', {
|
|
1662
|
+
iteration: this._iterations,
|
|
1663
|
+
task: { text: task.text },
|
|
1664
|
+
message: rawMsg,
|
|
1665
|
+
resumeAt: resumeAt.toISOString(),
|
|
1666
|
+
provider: currentProvider,
|
|
1667
|
+
fallbackProvider: fallbackId,
|
|
1668
|
+
workDir: this._workspaceRoot,
|
|
1669
|
+
gitRepo: this._gitRepo,
|
|
1670
|
+
gitBranch: this._gitBranch,
|
|
1671
|
+
});
|
|
1672
|
+
// Reset task so the fallback picks it up from scratch
|
|
1673
|
+
await todoWriteManager_1.todoWriter.resetToTodo(todoPath, task).catch(() => { });
|
|
1674
|
+
this._cb?.setActiveProvider?.(fallbackId);
|
|
1675
|
+
continue; // continue loop immediately with fallback provider
|
|
1676
|
+
}
|
|
1677
|
+
// No usable fallback — standard pause
|
|
1678
|
+
this._cb?.log(`⏸ Rate limit hit — ${rawMsg}. Auto-resume at ${resumeStr} (${suffix})`);
|
|
1679
|
+
this._notifyDiscord(`⏸ **Rate limit hit** — resuming at ${resumeStr} (${suffix})\n\`\`\`\n${rawMsg}\n\`\`\``);
|
|
1680
|
+
this._notifyWebhook('rate_limit', {
|
|
1681
|
+
iteration: this._iterations,
|
|
1682
|
+
task: { text: task.text },
|
|
1683
|
+
message: rawMsg,
|
|
1684
|
+
resumeAt: resumeAt.toISOString(),
|
|
1685
|
+
provider: currentProvider,
|
|
1686
|
+
workDir: this._workspaceRoot,
|
|
1687
|
+
gitRepo: this._gitRepo,
|
|
1688
|
+
gitBranch: this._gitBranch,
|
|
1689
|
+
});
|
|
1690
|
+
// Reset task so it gets picked up again after resume
|
|
1691
|
+
await todoWriteManager_1.todoWriter.resetToTodo(todoPath, task).catch(() => { });
|
|
1692
|
+
// Block here until resumed (timer or user clicks Retry Now)
|
|
1693
|
+
this._resumeAt = resumeAt;
|
|
1694
|
+
await this._pauseLoop(resumeMs);
|
|
1695
|
+
// After resume, if user stopped while paused, exit the while loop
|
|
1696
|
+
if (this._state !== 'running') {
|
|
1697
|
+
break;
|
|
1698
|
+
}
|
|
1699
|
+
continue; // pick up the same task at the top of the loop
|
|
1700
|
+
}
|
|
1701
|
+
// --- Thrashing: /compact is failing, Claude recommends /clear -----
|
|
1702
|
+
if (err instanceof ThrashingError) {
|
|
1703
|
+
const rawMsg = err.rawMessage.slice(0, 300);
|
|
1704
|
+
const provider = this._cb?.getActiveProvider() ?? '';
|
|
1705
|
+
this._cb?.log(`🗑 Autocompact thrashing (${provider}) — running /clear to reset session…\n${rawMsg}`);
|
|
1706
|
+
this._notifyDiscord(`🗑 **Autocompact thrashing** (${provider}) — running \`/clear\` to reset session…\n\`\`\`\n${rawMsg}\n\`\`\``);
|
|
1707
|
+
if (provider === 'claude-cli') {
|
|
1708
|
+
let sessionId = (0, sessionState_1.getSessionId)(this._workspaceRoot, 'claude-cli');
|
|
1709
|
+
if (!sessionId) {
|
|
1710
|
+
sessionId = (0, dispatcher_1.findLatestClaudeSession)(this._workspaceRoot);
|
|
1711
|
+
}
|
|
1712
|
+
if (sessionId) {
|
|
1713
|
+
try {
|
|
1714
|
+
await (0, claudeCliProvider_1.runClaudeClear)(sessionId, this._workspaceRoot, msg => this._cb?.log(msg));
|
|
1715
|
+
(0, sessionState_1.clearSessionId)(this._workspaceRoot, 'claude-cli');
|
|
1716
|
+
this._cb?.log('🗑 /clear complete — session reset, retrying task');
|
|
1717
|
+
this._notifyDiscord('🗑 `/clear` complete — session reset, retrying task');
|
|
1718
|
+
}
|
|
1719
|
+
catch (e) {
|
|
1720
|
+
this._cb?.log(`⚠️ /clear failed: ${e instanceof Error ? e.message : String(e)} — retrying anyway`);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
else {
|
|
1724
|
+
this._cb?.log('⚠️ No Claude session ID found for /clear — retrying anyway');
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
else if (provider === 'claude-tui') {
|
|
1728
|
+
let sessionId = (0, sessionState_1.getSessionId)(this._workspaceRoot, 'claude-tui');
|
|
1729
|
+
if (!sessionId) {
|
|
1730
|
+
sessionId = (0, claudeTuiProvider_1.getClaudeTuiLatestSessionId)(this._workspaceRoot);
|
|
1731
|
+
}
|
|
1732
|
+
if (sessionId) {
|
|
1733
|
+
try {
|
|
1734
|
+
await (0, claudeTuiProvider_1.runClaudeTuiClear)(this._workspaceRoot, sessionId, msg => this._cb?.log(msg));
|
|
1735
|
+
(0, sessionState_1.clearSessionId)(this._workspaceRoot, 'claude-tui');
|
|
1736
|
+
this._cb?.log('🗑 /clear (TUI) complete — session reset, retrying task');
|
|
1737
|
+
this._notifyDiscord('🗑 `/clear` (TUI) complete — session reset, retrying task');
|
|
1738
|
+
}
|
|
1739
|
+
catch (e) {
|
|
1740
|
+
this._cb?.log(`⚠️ /clear (TUI) failed: ${e instanceof Error ? e.message : String(e)} — retrying anyway`);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
else {
|
|
1744
|
+
this._cb?.log('⚠️ No Claude TUI session ID found for /clear — retrying anyway');
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
this._compactedTaskLines.delete(task.line); // allow compact to run again next time
|
|
1748
|
+
await todoWriteManager_1.todoWriter.resetToTodo(todoPath, task).catch(() => { });
|
|
1749
|
+
continue;
|
|
1750
|
+
}
|
|
1751
|
+
// --- Context length (OpenCode + Claude): run /compact once then retry
|
|
1752
|
+
if (err instanceof ContextLengthError && !this._compactedTaskLines.has(task.line)) {
|
|
1753
|
+
this._compactedTaskLines.add(task.line);
|
|
1754
|
+
const rawMsg = err.rawMessage.slice(0, 300);
|
|
1755
|
+
const provider = this._cb?.getActiveProvider() ?? '';
|
|
1756
|
+
this._cb?.log(`🗜 Context length exceeded (${provider}) — running /compact: ${rawMsg}`);
|
|
1757
|
+
this._notifyDiscord(`🗜 **Context length exceeded** (${provider}) — running \`/compact\`…\n\`\`\`\n${rawMsg}\n\`\`\``);
|
|
1758
|
+
if (provider === 'claude-cli') {
|
|
1759
|
+
// Resolve a Claude session ID — prefer the saved one, else scan
|
|
1760
|
+
// the .claude/projects jsonl folder for the most recent.
|
|
1761
|
+
let sessionId = (0, sessionState_1.getSessionId)(this._workspaceRoot, 'claude-cli');
|
|
1762
|
+
if (!sessionId) {
|
|
1763
|
+
sessionId = (0, dispatcher_1.findLatestClaudeSession)(this._workspaceRoot);
|
|
1764
|
+
}
|
|
1765
|
+
if (sessionId) {
|
|
1766
|
+
try {
|
|
1767
|
+
await (0, claudeCliProvider_1.runClaudeCompact)(sessionId, this._workspaceRoot, msg => this._cb?.log(msg));
|
|
1768
|
+
this._cb?.log('🗜 Claude compact complete — retrying task');
|
|
1769
|
+
this._notifyDiscord('🗜 Claude compact complete — retrying task');
|
|
1770
|
+
}
|
|
1771
|
+
catch (compactErr) {
|
|
1772
|
+
const compactMsg = compactErr instanceof Error ? compactErr.message : String(compactErr);
|
|
1773
|
+
this._cb?.log(`⚠️ Claude compact failed: ${compactMsg} — retrying anyway`);
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
else {
|
|
1777
|
+
this._cb?.log('⚠️ No Claude session ID found for compact — retrying task without compact');
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
else if (provider === 'claude-tui') {
|
|
1781
|
+
let sessionId = (0, sessionState_1.getSessionId)(this._workspaceRoot, 'claude-tui');
|
|
1782
|
+
if (!sessionId) {
|
|
1783
|
+
sessionId = (0, claudeTuiProvider_1.getClaudeTuiLatestSessionId)(this._workspaceRoot);
|
|
1784
|
+
}
|
|
1785
|
+
if (sessionId) {
|
|
1786
|
+
try {
|
|
1787
|
+
await (0, claudeTuiProvider_1.runClaudeTuiCompact)(this._workspaceRoot, sessionId, msg => this._cb?.log(msg));
|
|
1788
|
+
this._cb?.log('🗜 Claude TUI compact complete — retrying task');
|
|
1789
|
+
this._notifyDiscord('🗜 Claude TUI compact complete — retrying task');
|
|
1790
|
+
}
|
|
1791
|
+
catch (compactErr) {
|
|
1792
|
+
const compactMsg = compactErr instanceof Error ? compactErr.message : String(compactErr);
|
|
1793
|
+
this._cb?.log(`⚠️ Claude TUI compact failed: ${compactMsg} — retrying anyway`);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
else {
|
|
1797
|
+
this._cb?.log('⚠️ No Claude TUI session ID found for compact — retrying task without compact');
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
else {
|
|
1801
|
+
// OpenCode (existing behaviour)
|
|
1802
|
+
let sessionId = (0, sessionState_1.getSessionId)(this._workspaceRoot, 'opencode-cli');
|
|
1803
|
+
if (!sessionId) {
|
|
1804
|
+
sessionId = await (0, opencodeCliProvider_1.getLatestOpenCodeSessionId)(this._workspaceRoot, msg => this._cb?.log(msg));
|
|
1805
|
+
}
|
|
1806
|
+
if (sessionId) {
|
|
1807
|
+
try {
|
|
1808
|
+
await (0, opencodeCliProvider_1.runOpenCodeCompact)(sessionId, this._workspaceRoot, msg => this._cb?.log(msg));
|
|
1809
|
+
this._cb?.log('🗜 Compact complete — retrying task');
|
|
1810
|
+
this._notifyDiscord('🗜 Compact complete — retrying task');
|
|
1811
|
+
}
|
|
1812
|
+
catch (compactErr) {
|
|
1813
|
+
const compactMsg = compactErr instanceof Error ? compactErr.message : String(compactErr);
|
|
1814
|
+
this._cb?.log(`⚠️ Compact failed: ${compactMsg} — retrying anyway`);
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
else {
|
|
1818
|
+
this._cb?.log('⚠️ No OpenCode session ID found for compact — retrying task without compact');
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
await todoWriteManager_1.todoWriter.resetToTodo(todoPath, task).catch(() => { });
|
|
1822
|
+
continue;
|
|
1823
|
+
}
|
|
1824
|
+
// --- Context length already compacted or plan limit: pause + retry button
|
|
1825
|
+
if (err instanceof ContextLengthError) {
|
|
1826
|
+
const rawMsg = err.rawMessage.slice(0, 300);
|
|
1827
|
+
const provider = this._cb?.getActiveProvider() ?? '';
|
|
1828
|
+
this._cb?.log(`⏸ Context length exceeded (${provider}) and already compacted — pausing. Click Retry to resume.\n${rawMsg}`);
|
|
1829
|
+
this._notifyDiscord(`⏸ **Context length exceeded** (${provider}) — already compacted or plan limit hit. Pausing…\n\`\`\`\n${rawMsg}\n\`\`\``);
|
|
1830
|
+
this._notifyWebhook('rate_limit', {
|
|
1831
|
+
iteration: this._iterations,
|
|
1832
|
+
task: { text: task.text },
|
|
1833
|
+
message: rawMsg,
|
|
1834
|
+
resumeAt: new Date(Date.now() + 60 * 60_000).toISOString(),
|
|
1835
|
+
provider,
|
|
1836
|
+
workDir: this._workspaceRoot,
|
|
1837
|
+
gitRepo: this._gitRepo,
|
|
1838
|
+
gitBranch: this._gitBranch,
|
|
1839
|
+
});
|
|
1840
|
+
await todoWriteManager_1.todoWriter.resetToTodo(todoPath, task).catch(() => { });
|
|
1841
|
+
// No auto-resume time — user must click Retry manually
|
|
1842
|
+
this._resumeAt = undefined;
|
|
1843
|
+
await this._pauseLoop(); // pause indefinitely
|
|
1844
|
+
if (this._state !== 'running') {
|
|
1845
|
+
break;
|
|
1846
|
+
}
|
|
1847
|
+
continue;
|
|
1848
|
+
}
|
|
1849
|
+
// --- Normal task failure ------------------------------------------
|
|
1850
|
+
const duration = Math.round((Date.now() - taskStartTime) / 1000);
|
|
1851
|
+
this._failedCount++;
|
|
1852
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1853
|
+
this._cb?.log(`❌ Task failed: ${task.text} — ${msg}`);
|
|
1854
|
+
this._notifyWebhook('task_fail', {
|
|
1855
|
+
iteration: this._iterations,
|
|
1856
|
+
task: { text: task.text },
|
|
1857
|
+
duration,
|
|
1858
|
+
error: msg,
|
|
1859
|
+
workDir: this._workspaceRoot,
|
|
1860
|
+
gitRepo: this._gitRepo,
|
|
1861
|
+
gitBranch: this._gitBranch,
|
|
1862
|
+
});
|
|
1863
|
+
this._notifyDiscord(`❌ **Task failed:**\n${discordLabel(task.text)}\n\`${msg}\``);
|
|
1864
|
+
const afterRemainingFail = (0, todo_1.countRemaining)((0, todo_1.parseTodo)(todoPath));
|
|
1865
|
+
if (afterRemainingFail > 0) {
|
|
1866
|
+
const totalKnownFail = this._iterations + afterRemainingFail;
|
|
1867
|
+
this._notifyDiscord(`\ud83d\udcca Progress: ${this._iterations}/${totalKnownFail}`);
|
|
1868
|
+
this._notifyWebhook('task_progress', {
|
|
1869
|
+
iteration: this._iterations,
|
|
1870
|
+
total: totalKnownFail,
|
|
1871
|
+
remaining: afterRemainingFail,
|
|
1872
|
+
workDir: this._workspaceRoot,
|
|
1873
|
+
gitRepo: this._gitRepo,
|
|
1874
|
+
gitBranch: this._gitBranch,
|
|
1875
|
+
});
|
|
1876
|
+
} // Continue to next task rather than stopping the loop
|
|
1877
|
+
}
|
|
1878
|
+
this._currentTask = undefined;
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
/**
|
|
1882
|
+
* Suspend the loop in 'paused' state.
|
|
1883
|
+
* Resolves when retry() is called or (optionally) the timer fires.
|
|
1884
|
+
* MUST be called only from _runLoop.
|
|
1885
|
+
*/
|
|
1886
|
+
_pauseLoop(resumeAfterMs) {
|
|
1887
|
+
this._setState('paused');
|
|
1888
|
+
return new Promise(resolve => {
|
|
1889
|
+
this._resumeResolve = resolve;
|
|
1890
|
+
if (resumeAfterMs !== undefined && resumeAfterMs > 0) {
|
|
1891
|
+
this._retryScheduler.schedule(resumeAfterMs, () => {
|
|
1892
|
+
this._cb?.log('Rate limit timer expired — resuming loop automatically');
|
|
1893
|
+
this.retry();
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
});
|
|
1897
|
+
}
|
|
1898
|
+
/** Interrupt the idle no-task sleep — called by pollers when they append a task. */
|
|
1899
|
+
_wakeIdleSleep() {
|
|
1900
|
+
const w = this._idleSleepWake;
|
|
1901
|
+
this._idleSleepWake = null;
|
|
1902
|
+
w?.();
|
|
1903
|
+
}
|
|
1904
|
+
/** sleep() that resolves early when _wakeIdleSleep() is called. */
|
|
1905
|
+
_sleepOrWake(ms) {
|
|
1906
|
+
return new Promise(resolve => {
|
|
1907
|
+
const id = setTimeout(resolve, ms);
|
|
1908
|
+
this._idleSleepWake = () => { clearTimeout(id); resolve(); };
|
|
1909
|
+
});
|
|
1910
|
+
}
|
|
1911
|
+
/** sleep() that resolves immediately when the task-completion abort fires. */
|
|
1912
|
+
_sleepAbortable(ms) {
|
|
1913
|
+
return new Promise(resolve => {
|
|
1914
|
+
const id = setTimeout(resolve, ms);
|
|
1915
|
+
const prev = this._taskCompletionAbort;
|
|
1916
|
+
this._taskCompletionAbort = () => { clearTimeout(id); resolve(); prev?.(); };
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
/** Return when the task text appears with [x] status in the TODO.md file. */
|
|
1920
|
+
_waitForTaskCompletion(todoPath, task, claudeCursor = 0) {
|
|
1921
|
+
const isClaudeCli = this._cb?.getActiveProvider() === 'claude-cli';
|
|
1922
|
+
const isClaudeTui = this._cb?.getActiveProvider() === 'claude-tui';
|
|
1923
|
+
const iscopilotSdkProvider = this._cb?.getActiveProvider() === 'copilot-sdk';
|
|
1924
|
+
const isOpenCodeCli = this._cb?.getActiveProvider() === 'opencode-cli';
|
|
1925
|
+
const isOpencodeSdk = this._cb?.getActiveProvider() === 'opencode-sdk';
|
|
1926
|
+
return new Promise((resolve, reject) => {
|
|
1927
|
+
if (this._state !== 'running') {
|
|
1928
|
+
resolve();
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
const settings = this._settings;
|
|
1932
|
+
const timeoutMs = (settings.taskTimeoutMinutes ?? 30) * 60 * 1_000;
|
|
1933
|
+
const taskStartTime = Date.now();
|
|
1934
|
+
// Timeout is based on TODO.md inactivity, not total process runtime.
|
|
1935
|
+
// Every time TODO.md changes (any [~]/[x] write by the AI) this resets.
|
|
1936
|
+
// Only fires if TODO.md has been untouched for the full timeout duration.
|
|
1937
|
+
let lastTodoChangeTime = Date.now();
|
|
1938
|
+
// Shared with the JSONL inactivity poller — reset here so TODO.md changes
|
|
1939
|
+
// prevent the "Still working" reminder from firing unnecessarily.
|
|
1940
|
+
let lastActivityTime = Date.now();
|
|
1941
|
+
const found = () => {
|
|
1942
|
+
const updated = (0, todo_1.parseTodo)(todoPath);
|
|
1943
|
+
// 1. Prefer task ID (globally unique — set by appendTask on every new task).
|
|
1944
|
+
// 2. Line number with text verification (fast; guards against line-shift from
|
|
1945
|
+
// new tasks inserted above this one pointing to the wrong entry).
|
|
1946
|
+
// 3. Text-only fallback when there is no ID and the line has shifted.
|
|
1947
|
+
const byId = task.id ? updated.find(t => t.id === task.id) : undefined;
|
|
1948
|
+
const byLine = updated.find(t => t.line === task.line);
|
|
1949
|
+
const byLineVerified = (byLine && byLine.text === task.text) ? byLine : undefined;
|
|
1950
|
+
const byText = updated.find(t => t.text === task.text);
|
|
1951
|
+
const match = byId ?? byLineVerified ?? byText;
|
|
1952
|
+
// If the current task can no longer be found but there are no pending
|
|
1953
|
+
// tasks left at all, treat it as completed. This avoids re-prompt loops
|
|
1954
|
+
// where the CLI exits cleanly after finishing work but TODO matching was
|
|
1955
|
+
// invalidated by line shifts or post-processing.
|
|
1956
|
+
if (!match) {
|
|
1957
|
+
return (0, todo_1.countRemaining)(updated) === 0 ? true : undefined;
|
|
1958
|
+
}
|
|
1959
|
+
return match.status === 'done';
|
|
1960
|
+
};
|
|
1961
|
+
// Track "task not found" state to detect line-number shifts from new tasks.
|
|
1962
|
+
// If task becomes unfindable, give it 3 seconds grace before treating as lost.
|
|
1963
|
+
let taskLostAt = null;
|
|
1964
|
+
// Check immediately (AI might have already edited the file)
|
|
1965
|
+
if (found() === true) {
|
|
1966
|
+
resolve();
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
let poller;
|
|
1970
|
+
let stdoutWatcherRef;
|
|
1971
|
+
let exitWatcherRef;
|
|
1972
|
+
let todoWatcher;
|
|
1973
|
+
const endTurnTimers = [];
|
|
1974
|
+
// Set to true by cleanup() so stale onCliExit() calls that are still
|
|
1975
|
+
// sleeping don't send a spurious reminder after the task resolved.
|
|
1976
|
+
let cancelled = false;
|
|
1977
|
+
const cleanup = () => {
|
|
1978
|
+
cancelled = true;
|
|
1979
|
+
this._taskCompletionAbort = null;
|
|
1980
|
+
clearInterval(poller);
|
|
1981
|
+
for (const t of endTurnTimers) {
|
|
1982
|
+
clearTimeout(t);
|
|
1983
|
+
}
|
|
1984
|
+
endTurnTimers.length = 0;
|
|
1985
|
+
todoWatcher?.dispose();
|
|
1986
|
+
stdoutWatcherRef?.dispose();
|
|
1987
|
+
stdoutWatcherRef = undefined;
|
|
1988
|
+
exitWatcherRef?.dispose();
|
|
1989
|
+
exitWatcherRef = undefined;
|
|
1990
|
+
this._cb?.onActivityChange?.(undefined);
|
|
1991
|
+
};
|
|
1992
|
+
const check = () => {
|
|
1993
|
+
if (this._state !== 'running') {
|
|
1994
|
+
cleanup();
|
|
1995
|
+
resolve();
|
|
1996
|
+
return;
|
|
1997
|
+
}
|
|
1998
|
+
const state = found(); // returns true (done), false (undefined), or undefined (not found)
|
|
1999
|
+
if (state === true) {
|
|
2000
|
+
// Task explicitly marked [x] done
|
|
2001
|
+
lastTodoChangeTime = Date.now(); // reset inactivity clock
|
|
2002
|
+
lastActivityTime = Date.now();
|
|
2003
|
+
cleanup();
|
|
2004
|
+
resolve();
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
if (state === false) {
|
|
2008
|
+
// Task marked [~] in-progress or other non-done status
|
|
2009
|
+
lastTodoChangeTime = Date.now();
|
|
2010
|
+
lastActivityTime = Date.now();
|
|
2011
|
+
taskLostAt = null; // task found again, reset lost timer
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
// state === undefined: task not found in TODO.md
|
|
2015
|
+
if (taskLostAt === null) {
|
|
2016
|
+
// First time seeing task as lost — start the grace period
|
|
2017
|
+
taskLostAt = Date.now();
|
|
2018
|
+
this._cb?.log(`⚠️ Task became unfindable in TODO.md (line shift from new tasks?) — giving 3s grace period…`);
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
// Check if grace period expired
|
|
2022
|
+
if (Date.now() - taskLostAt > 3_000) {
|
|
2023
|
+
this._cb?.log(`⚠️ Task still unfindable after 3s grace period — treating as lost and resolving`);
|
|
2024
|
+
cleanup();
|
|
2025
|
+
resolve();
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
};
|
|
2029
|
+
todoWatcher = this._cb.fileWatcher.watch(todoPath, check);
|
|
2030
|
+
this._taskWatcher = todoWatcher;
|
|
2031
|
+
// Per-provider stdout capture file (only used for CLI providers)
|
|
2032
|
+
const activeProvider = this._cb?.getActiveProvider() ?? 'unknown';
|
|
2033
|
+
// Re-computed dynamically — sendToAi() (reminder path) rotates to a fresh
|
|
2034
|
+
// per-message file and updates the .latest pointer. Using a let + refresh
|
|
2035
|
+
// in the interval ensures checkStdout() always reads the current file.
|
|
2036
|
+
let resolvedStdoutFile = this._workspaceRoot
|
|
2037
|
+
? (0, sessionState_1.stdoutFilePath)(this._workspaceRoot, activeProvider)
|
|
2038
|
+
: null;
|
|
2039
|
+
// Helper: read stdout capture file handling both UTF-8 and UTF-16 LE (PowerShell default)
|
|
2040
|
+
const readStdoutFile = () => {
|
|
2041
|
+
if (!resolvedStdoutFile) {
|
|
2042
|
+
return '';
|
|
2043
|
+
}
|
|
2044
|
+
try {
|
|
2045
|
+
const buf = fs.readFileSync(resolvedStdoutFile);
|
|
2046
|
+
// Detect UTF-16 LE BOM (0xFF 0xFE)
|
|
2047
|
+
if (buf.length >= 2 && buf[0] === 0xFF && buf[1] === 0xFE) {
|
|
2048
|
+
return buf.toString('utf16le');
|
|
2049
|
+
}
|
|
2050
|
+
return buf.toString('utf8');
|
|
2051
|
+
}
|
|
2052
|
+
catch {
|
|
2053
|
+
return '';
|
|
2054
|
+
}
|
|
2055
|
+
};
|
|
2056
|
+
// Track how many characters of the stdout file we've already forwarded
|
|
2057
|
+
let lastStdoutLen = 0;
|
|
2058
|
+
// Check stdout file: forward any new content to Discord/webhook, detect rate limit / context errors
|
|
2059
|
+
const checkStdout = () => {
|
|
2060
|
+
if (!isClaudeCli && !isClaudeTui && !iscopilotSdkProvider && !isOpenCodeCli) {
|
|
2061
|
+
return;
|
|
2062
|
+
} // only CLI providers tee stdout
|
|
2063
|
+
const content = readStdoutFile();
|
|
2064
|
+
// Forward new output lines to Discord / webhook.
|
|
2065
|
+
// claude-cli: stream partial chunks so the operator can see live progress.
|
|
2066
|
+
// claude-tui: do NOT stream — the TUI writes noisy partial chunks; we
|
|
2067
|
+
// send one clean summary from the JSONL session file at task completion
|
|
2068
|
+
// (same approach as opencode).
|
|
2069
|
+
if (isClaudeCli && content.length > lastStdoutLen) {
|
|
2070
|
+
const newText = content.slice(lastStdoutLen).trim();
|
|
2071
|
+
lastStdoutLen = content.length;
|
|
2072
|
+
if (newText) {
|
|
2073
|
+
this._notifyDiscord(`🖥 **Claude output:**\n\`\`\`\n${newText}\n\`\`\``);
|
|
2074
|
+
this._notifyWebhook('claude_output', {
|
|
2075
|
+
iteration: this._iterations,
|
|
2076
|
+
task: { text: task.text },
|
|
2077
|
+
output: newText,
|
|
2078
|
+
workDir: this._workspaceRoot,
|
|
2079
|
+
gitRepo: this._gitRepo,
|
|
2080
|
+
gitBranch: this._gitBranch,
|
|
2081
|
+
});
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
else {
|
|
2085
|
+
lastStdoutLen = content.length; // keep cursor up to date
|
|
2086
|
+
}
|
|
2087
|
+
// Rate limit detection (Claude CLI + TUI)
|
|
2088
|
+
if (isClaudeCli || isClaudeTui) {
|
|
2089
|
+
const rlErr = rateLimit_1.RateLimitDetector.detect(content);
|
|
2090
|
+
if (rlErr) {
|
|
2091
|
+
cleanup();
|
|
2092
|
+
reject(rlErr);
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
// Context length error detection (OpenCode)
|
|
2097
|
+
if (isOpenCodeCli) {
|
|
2098
|
+
const lc = content.toLowerCase();
|
|
2099
|
+
if (lc.includes('maximum context length') || lc.includes('prompt is too long')) {
|
|
2100
|
+
cleanup();
|
|
2101
|
+
reject(new ContextLengthError(content.trim()));
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
// Context length error detection (Claude). Patterns observed:
|
|
2106
|
+
// "prompt is too long: 1018289 tokens > 1000000 maximum"
|
|
2107
|
+
// "Prompt is too long" (last_assistant_message in StopFailure hook)
|
|
2108
|
+
// "context_length_exceeded"
|
|
2109
|
+
// "Autocompact is thrashing" (context refills immediately after compact)
|
|
2110
|
+
if (isClaudeCli) {
|
|
2111
|
+
const lc = content.toLowerCase();
|
|
2112
|
+
if (lc.includes('autocompact is thrashing')) {
|
|
2113
|
+
cleanup();
|
|
2114
|
+
reject(new ThrashingError(content.trim()));
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
if (lc.includes('prompt is too long')
|
|
2118
|
+
|| lc.includes('context_length_exceeded')
|
|
2119
|
+
|| /tokens?\s*>\s*\d+\s*maximum/.test(lc)) {
|
|
2120
|
+
cleanup();
|
|
2121
|
+
reject(new ContextLengthError(content.trim()));
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
// Context length detection for claude-tui (same patterns)
|
|
2126
|
+
if (isClaudeTui) {
|
|
2127
|
+
const lc = content.toLowerCase();
|
|
2128
|
+
if (lc.includes('autocompact is thrashing')) {
|
|
2129
|
+
cleanup();
|
|
2130
|
+
reject(new ThrashingError(content.trim()));
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
if (lc.includes('prompt is too long')
|
|
2134
|
+
|| lc.includes('context_length_exceeded')
|
|
2135
|
+
|| /tokens?\s*>\s*\d+\s*maximum/.test(lc)) {
|
|
2136
|
+
cleanup();
|
|
2137
|
+
reject(new ContextLengthError(content.trim()));
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
};
|
|
2142
|
+
// Register abort hook so stop() can resolve this immediately
|
|
2143
|
+
this._taskCompletionAbort = () => { cleanup(); resolve(); };
|
|
2144
|
+
// Watch the per-provider stdout capture file for instant rate-limit detection.
|
|
2145
|
+
// Use the actual per-message file, not the legacy provider-level path, so the
|
|
2146
|
+
// watcher fires on the file the current process is writing to.
|
|
2147
|
+
const attachStdoutWatcher = (filePath) => {
|
|
2148
|
+
stdoutWatcherRef?.dispose();
|
|
2149
|
+
stdoutWatcherRef = filePath
|
|
2150
|
+
? this._cb.fileWatcher.watch(filePath, checkStdout)
|
|
2151
|
+
: undefined;
|
|
2152
|
+
};
|
|
2153
|
+
attachStdoutWatcher(resolvedStdoutFile);
|
|
2154
|
+
// Watch the exit file — written by withExitFile() in dispatcher.ts when the CLI
|
|
2155
|
+
// process finishes. CliExitHandler owns the decision tree of what to do.
|
|
2156
|
+
const isTaskDone = () => found() === true;
|
|
2157
|
+
const exitHandler = this._workspaceRoot
|
|
2158
|
+
? new cliExit_1.CliExitHandler(this._workspaceRoot, todoPath, task, taskStartTime, isTaskDone)
|
|
2159
|
+
: null;
|
|
2160
|
+
const onCliExit = async () => {
|
|
2161
|
+
if (this._state !== 'running') {
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
// Give TODO.md enough time to be fully flushed and for any final Claude
|
|
2165
|
+
// writes (session ID capture etc.) to settle before we declare it undone.
|
|
2166
|
+
await sleep(3_000);
|
|
2167
|
+
// A parallel path (todoWatcher / poller check()) may have already
|
|
2168
|
+
// resolved the promise while we were sleeping. Don't send a spurious
|
|
2169
|
+
// reminder to the next task's session.
|
|
2170
|
+
if (cancelled) {
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
2173
|
+
// Fast-path: if the stdout capture file already contains a rate-limit
|
|
2174
|
+
// or context-length phrase at exit time, raise immediately.
|
|
2175
|
+
const exitStdout = readStdoutFile();
|
|
2176
|
+
if (isClaudeCli) {
|
|
2177
|
+
const rlFromStdout = rateLimit_1.RateLimitDetector.detect(exitStdout);
|
|
2178
|
+
if (rlFromStdout) {
|
|
2179
|
+
cleanup();
|
|
2180
|
+
reject(rlFromStdout);
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
if (isOpenCodeCli) {
|
|
2185
|
+
const lc = exitStdout.toLowerCase();
|
|
2186
|
+
if (lc.includes('maximum context length') || lc.includes('prompt is too long')) {
|
|
2187
|
+
cleanup();
|
|
2188
|
+
reject(new ContextLengthError(exitStdout.trim()));
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
const decision = exitHandler?.decide() ?? { kind: 'remind' };
|
|
2193
|
+
if (decision.kind === 'done') {
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
if (decision.kind === 'deferred') {
|
|
2197
|
+
this._cb?.log(`↩️ CLI exited with task [~] deferred — moving to next pending task: ${discordLabel(task.text)}`);
|
|
2198
|
+
cleanup();
|
|
2199
|
+
resolve();
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2202
|
+
if (decision.kind === 'rate_limit') {
|
|
2203
|
+
cleanup();
|
|
2204
|
+
reject(decision.error);
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
if (decision.kind === 'give_up') {
|
|
2208
|
+
this._cb?.log(`↩️ CLI exited again without marking task done — auto-marking [x] and moving on: ${discordLabel(task.text)}`);
|
|
2209
|
+
// Auto-mark the task done so the loop doesn't re-pick the same
|
|
2210
|
+
// [ ] task on the next iteration, causing an infinite loop.
|
|
2211
|
+
await todoWriteManager_1.todoWriter.markDone(todoPath, task).catch(() => { });
|
|
2212
|
+
cleanup();
|
|
2213
|
+
resolve();
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
// decision.kind === 'remind'
|
|
2217
|
+
const elapsedMin = Math.round((Date.now() - taskStartTime) / 60_000);
|
|
2218
|
+
const msg = `⏳ CLI finished but task not yet marked done (${elapsedMin}m): ${discordLabel(task.text)}`;
|
|
2219
|
+
this._cb?.log(msg);
|
|
2220
|
+
this._notifyDiscord(msg);
|
|
2221
|
+
this._notifyWebhook('task_checkin', {
|
|
2222
|
+
iteration: this._iterations,
|
|
2223
|
+
task: { text: task.text },
|
|
2224
|
+
elapsedMinutes: elapsedMin,
|
|
2225
|
+
workDir: this._workspaceRoot,
|
|
2226
|
+
gitRepo: this._gitRepo,
|
|
2227
|
+
gitBranch: this._gitBranch,
|
|
2228
|
+
});
|
|
2229
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
2230
|
+
// Read the actual current marker from TODO.md so the reminder is accurate.
|
|
2231
|
+
// If Claude never started the task it will be [ ]; if it marked it in-progress
|
|
2232
|
+
// but then exited it will be [~]. Using the wrong marker causes the AI to fail
|
|
2233
|
+
// to locate the line and exit again without making any change.
|
|
2234
|
+
const currentTasks = (0, todo_1.parseTodo)(todoPath);
|
|
2235
|
+
const currentLine = currentTasks.find(t => t.line === task.line || t.text === task.text);
|
|
2236
|
+
const currentMarker = currentLine?.status === 'in-progress' ? '~' : ' ';
|
|
2237
|
+
const reminder = [
|
|
2238
|
+
`⚠️ ACTION REQUIRED: Your process has finished but your current task is NOT marked done in TODO.md.`,
|
|
2239
|
+
`Do NOT stop. Do NOT wait. Update TODO.md right now, then continue to the next task.`,
|
|
2240
|
+
`Read TODO.md now: cat ${todoPath}`,
|
|
2241
|
+
``,
|
|
2242
|
+
`Find line ${task.line} (task: ${task.text}):`,
|
|
2243
|
+
` - [${currentMarker}] ${task.text}`,
|
|
2244
|
+
``,
|
|
2245
|
+
`You MUST change it to one of:`,
|
|
2246
|
+
` [x] – done: - [x] ${date} ${task.text}`,
|
|
2247
|
+
` [~] – in progress: - [~] ${task.text}`,
|
|
2248
|
+
``,
|
|
2249
|
+
`After saving the file, immediately continue to the next [ ] task. Do not exit or wait for instructions.`,
|
|
2250
|
+
].join('\n');
|
|
2251
|
+
this._cb?.log(`⚠️ CLI exited: reminding AI to mark TODO.md (${elapsedMin}m elapsed)`);
|
|
2252
|
+
try {
|
|
2253
|
+
const reminderFile = (0, messageBuilder_1.writeMessageFile)(this._workspaceRoot, reminder);
|
|
2254
|
+
await this._cb.sendToAi(reminder, task.text, false, reminderFile);
|
|
2255
|
+
}
|
|
2256
|
+
catch { /* ignore */ }
|
|
2257
|
+
};
|
|
2258
|
+
// Track which exit file we are currently watching so the poller can
|
|
2259
|
+
// re-attach when sendToAi() rotates to a new per-message exit file.
|
|
2260
|
+
let watchedExitFile = null;
|
|
2261
|
+
// Path of the exit file for which onCliExit() has already been invoked.
|
|
2262
|
+
// Prevents the watcher AND the poller fallback from both firing onCliExit()
|
|
2263
|
+
// for the same exit event (the guard is set by whichever fires first).
|
|
2264
|
+
let handledExitFile = null;
|
|
2265
|
+
const attachExitWatcher = (filePath) => {
|
|
2266
|
+
if (filePath === watchedExitFile) {
|
|
2267
|
+
return;
|
|
2268
|
+
} // already watching this file
|
|
2269
|
+
exitWatcherRef?.dispose();
|
|
2270
|
+
watchedExitFile = filePath;
|
|
2271
|
+
exitWatcherRef = this._cb.fileWatcher.watch(filePath, () => {
|
|
2272
|
+
if (handledExitFile === filePath) {
|
|
2273
|
+
return;
|
|
2274
|
+
} // poller already handled
|
|
2275
|
+
try {
|
|
2276
|
+
const content = fs.readFileSync(filePath, 'utf8').trim();
|
|
2277
|
+
if (content === '') {
|
|
2278
|
+
return;
|
|
2279
|
+
} // file cleared at task start — ignore
|
|
2280
|
+
}
|
|
2281
|
+
catch {
|
|
2282
|
+
return;
|
|
2283
|
+
}
|
|
2284
|
+
handledExitFile = filePath;
|
|
2285
|
+
void onCliExit();
|
|
2286
|
+
});
|
|
2287
|
+
};
|
|
2288
|
+
// opencode-sdk: the persistent in-process SDK doesn't use the CLI exit-file
|
|
2289
|
+
// reminder flow — doing so causes a re-prompt loop (the SDK writes '0' to the
|
|
2290
|
+
// exit file when session.idle fires, the poller sees it, calls onCliExit(),
|
|
2291
|
+
// which re-sends the prompt, which loops). Instead, the poller resolves this
|
|
2292
|
+
// promise directly when isOpencodeSdkBusy() becomes false (see below).
|
|
2293
|
+
if (!isOpencodeSdk && this._workspaceRoot) {
|
|
2294
|
+
attachExitWatcher((0, sessionState_1.exitFilePath)(this._workspaceRoot, activeProvider));
|
|
2295
|
+
}
|
|
2296
|
+
// Inactivity-based check-in: track Claude JSONL byte size every 3 s.
|
|
2297
|
+
// After 15 minutes of silence (no new bytes), send the TODO.md reminder.
|
|
2298
|
+
// Resets when Claude writes again so we don't spam.
|
|
2299
|
+
const INACTIVITY_MS = 15 * 60 * 1_000;
|
|
2300
|
+
let endTurnSeen = false;
|
|
2301
|
+
let lastJSONLSize = claudeCursor > 0 && this._workspaceRoot
|
|
2302
|
+
? (0, dispatcher_1.getClaudeSessionCursor)(this._workspaceRoot) : 0;
|
|
2303
|
+
// lastActivityTime is declared above (shared with check())
|
|
2304
|
+
let reminderPending = true; // allow one reminder per quiet period
|
|
2305
|
+
let lastActivity;
|
|
2306
|
+
poller = setInterval(async () => {
|
|
2307
|
+
check();
|
|
2308
|
+
checkStdout(); // also poll stdout every tick — file watcher can miss events on Linux
|
|
2309
|
+
if (!this._workspaceRoot) {
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
2312
|
+
// If sendToAi() was called (e.g. reminder path) it rotates to a new
|
|
2313
|
+
// per-message stdout/exit file. Re-attach both watchers so the next
|
|
2314
|
+
// process's output and exit are both detected even though the paths changed.
|
|
2315
|
+
const latestStdout = this._workspaceRoot ? (0, sessionState_1.stdoutFilePath)(this._workspaceRoot, activeProvider) : null;
|
|
2316
|
+
if (latestStdout && latestStdout !== resolvedStdoutFile) {
|
|
2317
|
+
resolvedStdoutFile = latestStdout;
|
|
2318
|
+
lastStdoutLen = 0; // reset cursor — new file starts from byte 0
|
|
2319
|
+
attachStdoutWatcher(resolvedStdoutFile);
|
|
2320
|
+
}
|
|
2321
|
+
const latestExit = (0, sessionState_1.exitFilePath)(this._workspaceRoot, activeProvider);
|
|
2322
|
+
if (!isOpencodeSdk && latestExit !== watchedExitFile) {
|
|
2323
|
+
attachExitWatcher(latestExit);
|
|
2324
|
+
}
|
|
2325
|
+
// opencode-sdk: resolve _waitForTaskCompletion as soon as the SDK
|
|
2326
|
+
// session goes idle (isOpencodeSdkBusy false). This avoids the
|
|
2327
|
+
// CLI-style onCliExit() reminder re-prompt loop that the exit-file
|
|
2328
|
+
// mechanism would otherwise trigger.
|
|
2329
|
+
if (isOpencodeSdk && this._workspaceRoot && !(0, opencodeSdkProvider_1.isOpencodeSdkBusy)(this._workspaceRoot)) {
|
|
2330
|
+
check(); // one last todo-file check before resolving
|
|
2331
|
+
if (!cancelled) {
|
|
2332
|
+
cleanup();
|
|
2333
|
+
resolve();
|
|
2334
|
+
}
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
// Poller-based exit fallback: the VS Code file-system watcher can miss
|
|
2338
|
+
// events (gitignored dirs, inotify limits, fast exits before re-attach).
|
|
2339
|
+
// Read the exit file directly every tick and trigger onCliExit() if it
|
|
2340
|
+
// became non-empty without the watcher firing.
|
|
2341
|
+
if (!isOpencodeSdk && latestExit && latestExit !== handledExitFile) {
|
|
2342
|
+
try {
|
|
2343
|
+
if (fs.readFileSync(latestExit, 'utf8').trim() !== '') {
|
|
2344
|
+
handledExitFile = latestExit;
|
|
2345
|
+
void onCliExit();
|
|
2346
|
+
}
|
|
2347
|
+
else if (isOpenCodeCli && this._workspaceRoot) {
|
|
2348
|
+
// Empty exit file: the opencode process may have been killed before
|
|
2349
|
+
// writing the exit code (VS Code restart, OOM, terminal force-close).
|
|
2350
|
+
// If opencode is no longer active (hooks JSONL stale > 90s) and a
|
|
2351
|
+
// minimum grace period has elapsed, treat it as an unclean exit so
|
|
2352
|
+
// CliExitHandler can decide whether to retry, remind, or give up.
|
|
2353
|
+
const minWaitMs = 2 * 60 * 1_000; // 2 min grace period for startup
|
|
2354
|
+
if (Date.now() - taskStartTime > minWaitMs
|
|
2355
|
+
&& !(0, openCodeHooksManager_1.isOpenCodeCliActive)(this._workspaceRoot)) {
|
|
2356
|
+
handledExitFile = latestExit;
|
|
2357
|
+
void onCliExit();
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
catch { /* file not yet written — ignore */ }
|
|
2362
|
+
}
|
|
2363
|
+
// Parse rich JSONL state: end_turn, active tool, bash progress
|
|
2364
|
+
if (claudeCursor > 0) {
|
|
2365
|
+
const sessionState = (0, dispatcher_1.parseClaudeStateSince)(this._workspaceRoot, claudeCursor);
|
|
2366
|
+
// end_turn detection — fast-path on Linux where inotify can lag
|
|
2367
|
+
if (!endTurnSeen && sessionState.hasEndTurn) {
|
|
2368
|
+
endTurnSeen = true;
|
|
2369
|
+
this._cb?.log('end_turn detected in Claude JSONL — checking TODO.md');
|
|
2370
|
+
endTurnTimers.push(setTimeout(check, 800));
|
|
2371
|
+
endTurnTimers.push(setTimeout(check, 2_500));
|
|
2372
|
+
}
|
|
2373
|
+
// Surface current tool activity to sidebar
|
|
2374
|
+
const activity = sessionState.hasEndTurn
|
|
2375
|
+
? undefined
|
|
2376
|
+
: (sessionState.activeToolStatus ?? (sessionState.hasProgress ? 'Running command\u2026' : undefined));
|
|
2377
|
+
if (activity !== lastActivity) {
|
|
2378
|
+
lastActivity = activity;
|
|
2379
|
+
this._cb?.onActivityChange?.(activity);
|
|
2380
|
+
}
|
|
2381
|
+
// Rate limit detection — reject immediately so _runLoop can pause
|
|
2382
|
+
if (sessionState.rateLimitMessage) {
|
|
2383
|
+
cleanup();
|
|
2384
|
+
reject(rateLimit_1.RateLimitDetector.toError(sessionState.rateLimitMessage));
|
|
2385
|
+
return;
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
// Also check stdout capture file as poller fallback (watcher handles most cases)
|
|
2389
|
+
checkStdout();
|
|
2390
|
+
// Track JSONL activity
|
|
2391
|
+
const currentSize = (0, dispatcher_1.getClaudeSessionCursor)(this._workspaceRoot);
|
|
2392
|
+
if (currentSize !== lastJSONLSize) {
|
|
2393
|
+
lastJSONLSize = currentSize;
|
|
2394
|
+
lastActivityTime = Date.now();
|
|
2395
|
+
reminderPending = true; // new activity — allow a fresh reminder after next silence
|
|
2396
|
+
return;
|
|
2397
|
+
}
|
|
2398
|
+
// TODO.md inactivity timeout — fires when TODO.md has not been touched
|
|
2399
|
+
// for the full timeout duration (resets on every TODO.md write).
|
|
2400
|
+
// Checked on every tick independently of the JSONL reminder flow, so it
|
|
2401
|
+
// fires even after a reminder has already been sent and reminderPending=false.
|
|
2402
|
+
{
|
|
2403
|
+
const idleMs = Date.now() - lastTodoChangeTime;
|
|
2404
|
+
if (idleMs >= timeoutMs) {
|
|
2405
|
+
cleanup();
|
|
2406
|
+
const minutes = settings.taskTimeoutMinutes ?? 30;
|
|
2407
|
+
if (settings.retryOnTimeout) {
|
|
2408
|
+
await todoWriteManager_1.todoWriter.resetToTodo(todoPath, task).catch(() => { });
|
|
2409
|
+
const msg = `⏱ TODO.md idle for ${minutes}m — retrying: ${discordLabel(task.text)}`;
|
|
2410
|
+
this._cb?.log(msg);
|
|
2411
|
+
this._notifyDiscord(msg);
|
|
2412
|
+
this._notifyWebhook('task_checkin', {
|
|
2413
|
+
iteration: this._iterations,
|
|
2414
|
+
task: { text: task.text },
|
|
2415
|
+
elapsedMinutes: minutes,
|
|
2416
|
+
timedOut: true,
|
|
2417
|
+
retrying: true,
|
|
2418
|
+
workDir: this._workspaceRoot,
|
|
2419
|
+
gitRepo: this._gitRepo,
|
|
2420
|
+
gitBranch: this._gitBranch,
|
|
2421
|
+
});
|
|
2422
|
+
resolve(); // loop will pick it up again as a fresh [ ] task
|
|
2423
|
+
}
|
|
2424
|
+
else {
|
|
2425
|
+
reject(new Error(`Task timed out after ${minutes} minutes of TODO.md inactivity`));
|
|
2426
|
+
}
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
// No new bytes — check if we've been quiet long enough
|
|
2431
|
+
if (!reminderPending) {
|
|
2432
|
+
return;
|
|
2433
|
+
}
|
|
2434
|
+
if (Date.now() - lastActivityTime < INACTIVITY_MS) {
|
|
2435
|
+
return;
|
|
2436
|
+
}
|
|
2437
|
+
// 15+ minutes of JSONL silence — send one reminder
|
|
2438
|
+
reminderPending = false;
|
|
2439
|
+
if (this._state !== 'running') {
|
|
2440
|
+
return;
|
|
2441
|
+
}
|
|
2442
|
+
const elapsedMin = Math.round((Date.now() - taskStartTime) / 60_000);
|
|
2443
|
+
const msg = `⏳ Still working... (${elapsedMin}m elapsed): ${discordLabel(task.text)}`;
|
|
2444
|
+
this._cb?.log(msg);
|
|
2445
|
+
this._notifyDiscord(msg);
|
|
2446
|
+
this._notifyWebhook('task_checkin', {
|
|
2447
|
+
iteration: this._iterations,
|
|
2448
|
+
task: { text: task.text },
|
|
2449
|
+
elapsedMinutes: elapsedMin,
|
|
2450
|
+
workDir: this._workspaceRoot,
|
|
2451
|
+
gitRepo: this._gitRepo,
|
|
2452
|
+
gitBranch: this._gitBranch,
|
|
2453
|
+
});
|
|
2454
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
2455
|
+
const currentTasks2 = (0, todo_1.parseTodo)(todoPath);
|
|
2456
|
+
const currentLine2 = currentTasks2.find(t => t.line === task.line || t.text === task.text);
|
|
2457
|
+
const currentMarker2 = currentLine2?.status === 'in-progress' ? '~' : ' ';
|
|
2458
|
+
const reminder = [
|
|
2459
|
+
`REMINDER: you have an unfinished task. Read TODO.md now: cat ${todoPath}`,
|
|
2460
|
+
``,
|
|
2461
|
+
`Find line ${task.line} (task: ${task.text}):`,
|
|
2462
|
+
` - [${currentMarker2}] ${task.text}`,
|
|
2463
|
+
``,
|
|
2464
|
+
`When done, change it to:`,
|
|
2465
|
+
` - [x] ${date} ${task.text}`,
|
|
2466
|
+
``,
|
|
2467
|
+
`If still in progress, mark it:`,
|
|
2468
|
+
` - [~] ${task.text}`,
|
|
2469
|
+
``,
|
|
2470
|
+
`Save the file. Do NOT exit without updating that line.`,
|
|
2471
|
+
].join('\n');
|
|
2472
|
+
this._cb?.log(`⚠️ Check-in: reminding AI to mark TODO.md (${elapsedMin}m, JSONL quiet for 3m)`);
|
|
2473
|
+
try {
|
|
2474
|
+
const reminderFile = (0, messageBuilder_1.writeMessageFile)(this._workspaceRoot, reminder);
|
|
2475
|
+
await this._cb.sendToAi(reminder, task.text, false, reminderFile);
|
|
2476
|
+
}
|
|
2477
|
+
catch { /* ignore */ }
|
|
2478
|
+
}, 3_000);
|
|
2479
|
+
});
|
|
2480
|
+
}
|
|
2481
|
+
_disposeWatcher() {
|
|
2482
|
+
this._taskWatcher?.dispose();
|
|
2483
|
+
this._taskWatcher = undefined;
|
|
2484
|
+
}
|
|
2485
|
+
_setState(state, taskText) {
|
|
2486
|
+
this._state = state;
|
|
2487
|
+
this._cb?.onStatusChange(state, taskText);
|
|
2488
|
+
}
|
|
2489
|
+
_notifyWebhook(event, payload) {
|
|
2490
|
+
this._webhook?.send(event, payload);
|
|
2491
|
+
}
|
|
2492
|
+
_notifyDiscord(message) {
|
|
2493
|
+
const s = this._settings;
|
|
2494
|
+
if (!s) {
|
|
2495
|
+
return;
|
|
2496
|
+
}
|
|
2497
|
+
if (s.discordToken && s.discordChannelId) {
|
|
2498
|
+
(0, webhook_1.sendDiscordBotMessage)(s.discordToken, s.discordChannelId, message);
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
exports.TaskLoopRunner = TaskLoopRunner;
|
|
2503
|
+
/** Singleton runner — one loop per workspace session. */
|
|
2504
|
+
exports.taskLoopRunner = new TaskLoopRunner();
|
|
2505
|
+
//# sourceMappingURL=taskLoop.js.map
|