teamcopilot 0.3.6 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat/index.js +104 -81
- package/dist/cronjob/index.js +2 -0
- package/dist/cronjobs/index.js +822 -0
- package/dist/cronjobs/scheduler.js +936 -0
- package/dist/frontend/assets/{cssMode-BRVRAYCz.js → cssMode-Cqdl5sUM.js} +1 -1
- package/dist/frontend/assets/{freemarker2-B5FvHwsO.js → freemarker2-ykAhuplU.js} +1 -1
- package/dist/frontend/assets/{handlebars-DWX2asql.js → handlebars-DX_JwRM8.js} +1 -1
- package/dist/frontend/assets/{html-BEBxxD9G.js → html-Bi_zOcbU.js} +1 -1
- package/dist/frontend/assets/{htmlMode-B2LbPTwC.js → htmlMode-CkAUoAah.js} +1 -1
- package/dist/frontend/assets/index-Ba9bElZm.css +1 -0
- package/dist/frontend/assets/{index-D3TE04C5.js → index-Cgozj4fx.js} +245 -242
- package/dist/frontend/assets/{javascript-Bh4JwoPV.js → javascript-D3Rjwp97.js} +1 -1
- package/dist/frontend/assets/{jsonMode-7j-aplXT.js → jsonMode-K4i6LjP2.js} +1 -1
- package/dist/frontend/assets/{liquid-BP4OxkO7.js → liquid-D8F4-sAz.js} +1 -1
- package/dist/frontend/assets/{mdx-C1OIcGbY.js → mdx-C2xw8PNz.js} +1 -1
- package/dist/frontend/assets/{python-BO8Wy5jz.js → python-CqTGfu2v.js} +1 -1
- package/dist/frontend/assets/{razor-BDtqXvAH.js → razor-DFSsPzdZ.js} +1 -1
- package/dist/frontend/assets/{tsMode-D22HcCuX.js → tsMode-BkLQEtPb.js} +1 -1
- package/dist/frontend/assets/{typescript-CagwEzRw.js → typescript-CE_GQ-M1.js} +1 -1
- package/dist/frontend/assets/{xml-fE5sGZ5z.js → xml-CGjMtNcA.js} +1 -1
- package/dist/frontend/assets/{yaml-CZMoG4WG.js → yaml-Zju9kuFB.js} +1 -1
- package/dist/frontend/index.html +2 -2
- package/dist/index.js +3 -0
- package/dist/types/cronjob.js +2 -0
- package/dist/utils/chat-prompt-context.js +65 -0
- package/dist/utils/chat-session.js +12 -0
- package/dist/utils/index.js +27 -0
- package/dist/utils/workflow-interruption.js +5 -2
- package/dist/utils/workflow-run-validation.js +25 -0
- package/dist/utils/workspace-sync.js +17 -0
- package/dist/workflows/index.js +24 -25
- package/dist/workspace_files/.opencode/plugins/apply-patch-session-diff.ts +2 -2
- package/dist/workspace_files/.opencode/plugins/askCronjobUser.ts +106 -0
- package/dist/workspace_files/.opencode/plugins/manageCronjobTodos.ts +190 -0
- package/dist/workspace_files/.opencode/plugins/manageCronjobs.ts +376 -0
- package/dist/workspace_files/.opencode/plugins/markCronjobCompleted.ts +107 -0
- package/dist/workspace_files/.opencode/plugins/markCronjobFailed.ts +107 -0
- package/dist/workspace_files/AGENTS.md +51 -1
- package/package.json +1 -1
- package/prisma/generated/client/edge.js +50 -3
- package/prisma/generated/client/index-browser.js +47 -0
- package/prisma/generated/client/index.d.ts +13918 -7530
- package/prisma/generated/client/index.js +50 -3
- package/prisma/generated/client/package.json +1 -1
- package/prisma/generated/client/schema.prisma +72 -1
- package/prisma/generated/client/wasm.js +50 -3
- package/prisma/migrations/20260508050030_add_cronjobs/migration.sql +78 -0
- package/prisma/migrations/20260508093158_add_structured_cronjob_schedules/migration.sql +23 -0
- package/prisma/migrations/20260508105129_add_cronjob_targets/migration.sql +50 -0
- package/prisma/migrations/20260509044545_flatten_cronjob_schema/migration.sql +88 -0
- package/prisma/migrations/20260509052232_simplify_cronjob_schedule_storage/migration.sql +42 -0
- package/prisma/migrations/20260509054000_remove_chat_session_source_add_cronjob_run_indexes/migration.sql +28 -0
- package/prisma/migrations/20260509061000_cascade_cronjob_run_links/migration.sql +29 -0
- package/prisma/migrations/20260513073541_add_cronjob_run_todos/migration.sql +18 -0
- package/prisma/migrations/20260513133021_add_cronjob_user_response_wait/migration.sql +29 -0
- package/prisma/migrations/20260513135733_add_cronjob_user_handoff_state/migration.sql +30 -0
- package/prisma/migrations/20260513142511_drop_awaiting_user_response/migration.sql +35 -0
- package/prisma/migrations/20260514032204_simplify_cronjob_run_lifecycle/migration.sql +34 -0
- package/prisma/migrations/20260514043000_clear_cronjob_run_history/migration.sql +6 -0
- package/prisma/migrations/20260515094618_add_todo_list_version/migration.sql +32 -0
- package/prisma/migrations/20260516082714_add_cronjob_monitor_timeout/migration.sql +38 -0
- package/prisma/migrations/20260516083452_allow_decimal_cronjob_timeout/migration.sql +37 -0
- package/prisma/migrations/20260516084455_add_cronjob_timeout_defaults/migration.sql +31 -0
- package/prisma/schema.prisma +71 -1
- package/dist/frontend/assets/index-D1Hcz_bo.css +0 -1
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.getSessionStatusTypeForSession = getSessionStatusTypeForSession;
|
|
4
|
+
exports.sessionHasPendingInputForLatestAssistantMessage = sessionHasPendingInputForLatestAssistantMessage;
|
|
4
5
|
exports.normalizeStaleRunningTools = normalizeStaleRunningTools;
|
|
5
6
|
const assert_1 = require("./assert");
|
|
6
7
|
function getSessionStatusTypeForSession(statusMap, sessionId) {
|
|
7
8
|
const status = statusMap[sessionId];
|
|
8
9
|
return status ? status.type : 'idle';
|
|
9
10
|
}
|
|
11
|
+
function sessionHasPendingInputForLatestAssistantMessage(args) {
|
|
12
|
+
if (args.latestAssistantMessageId === null) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
return (args.pendingQuestions.some((question) => question.sessionID === args.opencodeSessionId
|
|
16
|
+
&& question.tool?.messageID === args.latestAssistantMessageId)
|
|
17
|
+
|| args.pendingPermissions.some((permission) => permission.sessionID === args.opencodeSessionId
|
|
18
|
+
&& permission.tool?.messageID === args.latestAssistantMessageId)
|
|
19
|
+
|| args.customPendingPermissions.some((permission) => permission.opencode_session_id === args.opencodeSessionId
|
|
20
|
+
&& permission.message_id === args.latestAssistantMessageId));
|
|
21
|
+
}
|
|
10
22
|
function normalizeStaleRunningTools(messages, sessionStatusType) {
|
|
11
23
|
const isSessionBusy = sessionStatusType === 'busy' || sessionStatusType === 'retry';
|
|
12
24
|
if (isSessionBusy) {
|
package/dist/utils/index.js
CHANGED
|
@@ -3,11 +3,38 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.reconcileRunningCronsAndWorkflowRunsOnStartup = reconcileRunningCronsAndWorkflowRunsOnStartup;
|
|
6
7
|
exports.apiHandler = apiHandler;
|
|
7
8
|
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
8
9
|
const client_1 = __importDefault(require("../prisma/client"));
|
|
9
10
|
const assert_1 = require("./assert");
|
|
10
11
|
const jwt_secret_1 = require("./jwt-secret");
|
|
12
|
+
function nowMs() {
|
|
13
|
+
return BigInt(Date.now());
|
|
14
|
+
}
|
|
15
|
+
async function reconcileRunningCronsAndWorkflowRunsOnStartup() {
|
|
16
|
+
const completedAt = nowMs();
|
|
17
|
+
const workflowError = "Workflow run was interrupted because TeamCopilot restarted.";
|
|
18
|
+
await client_1.default.workflow_runs.updateMany({
|
|
19
|
+
where: { status: "running" },
|
|
20
|
+
data: {
|
|
21
|
+
status: "failed",
|
|
22
|
+
completed_at: completedAt,
|
|
23
|
+
error_message: workflowError,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
await client_1.default.cronjob_runs.updateMany({
|
|
27
|
+
where: {
|
|
28
|
+
status: "running",
|
|
29
|
+
cronjob: { target_type: "workflow" },
|
|
30
|
+
},
|
|
31
|
+
data: {
|
|
32
|
+
status: "failed",
|
|
33
|
+
completed_at: completedAt,
|
|
34
|
+
error_message: "Workflow cronjob run was interrupted because TeamCopilot restarted.",
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
11
38
|
function apiHandler(handler, requireAuth) {
|
|
12
39
|
return async (req, res, next) => {
|
|
13
40
|
try {
|
|
@@ -3,13 +3,16 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.usesWorkflowDatabaseAbortMarker = usesWorkflowDatabaseAbortMarker;
|
|
6
7
|
exports.isWorkflowSessionInterrupted = isWorkflowSessionInterrupted;
|
|
7
8
|
exports.markWorkflowSessionAborted = markWorkflowSessionAborted;
|
|
8
9
|
const client_1 = __importDefault(require("../prisma/client"));
|
|
9
10
|
const opencode_client_1 = require("./opencode-client");
|
|
11
|
+
function usesWorkflowDatabaseAbortMarker(sessionId) {
|
|
12
|
+
return sessionId.startsWith("manual-") || sessionId.startsWith("api-") || sessionId.startsWith("cronjob-");
|
|
13
|
+
}
|
|
10
14
|
async function isWorkflowSessionInterrupted(sessionId, workspaceDir) {
|
|
11
|
-
|
|
12
|
-
if (usesDatabaseAbortMarker) {
|
|
15
|
+
if (usesWorkflowDatabaseAbortMarker(sessionId)) {
|
|
13
16
|
const aborted = await client_1.default.workflow_aborted_sessions.findUnique({
|
|
14
17
|
where: { session_id: sessionId }
|
|
15
18
|
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.assertUserCanRunWorkflow = assertUserCanRunWorkflow;
|
|
4
|
+
const resource_access_1 = require("./resource-access");
|
|
5
|
+
const workflow_1 = require("./workflow");
|
|
6
|
+
const workflow_approval_snapshot_1 = require("./workflow-approval-snapshot");
|
|
7
|
+
async function assertUserCanRunWorkflow(slug, userId) {
|
|
8
|
+
await (0, workflow_1.readWorkflowManifestAndEnsurePermissions)(slug);
|
|
9
|
+
const approvalState = await (0, workflow_approval_snapshot_1.getWorkflowSnapshotApprovalState)(slug);
|
|
10
|
+
if (!approvalState.is_current_code_approved) {
|
|
11
|
+
throw {
|
|
12
|
+
status: 403,
|
|
13
|
+
message: "Workflow is not approved for the current code version"
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const permissionSummary = await (0, resource_access_1.getResourceAccessSummary)("workflow", slug, userId);
|
|
17
|
+
if (!permissionSummary.can_edit) {
|
|
18
|
+
throw {
|
|
19
|
+
status: 403,
|
|
20
|
+
message: permissionSummary.is_locked_due_to_missing_users
|
|
21
|
+
? "Workflow cannot be run because no allowed users remain"
|
|
22
|
+
: "You do not have permission to run this workflow. Please contact the workflow owner to request permission."
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -17,6 +17,7 @@ const util_1 = require("util");
|
|
|
17
17
|
const crypto_1 = __importDefault(require("crypto"));
|
|
18
18
|
const assert_1 = require("./assert");
|
|
19
19
|
const runtime_paths_1 = require("./runtime-paths");
|
|
20
|
+
const client_1 = require("../../prisma/generated/client");
|
|
20
21
|
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
21
22
|
const WORKSPACE_DB_DIRECTORY = ".sqlite";
|
|
22
23
|
const WORKSPACE_DB_FILENAME = "data.db";
|
|
@@ -51,6 +52,21 @@ function workspaceDatabaseExists() {
|
|
|
51
52
|
function ensureWorkspaceDatabaseDirectory() {
|
|
52
53
|
fs_1.default.mkdirSync(path_1.default.dirname(getWorkspaceDatabasePath()), { recursive: true });
|
|
53
54
|
}
|
|
55
|
+
async function repairWorkspaceCronjobRunUniquenessIndex(workspaceDatabaseUrl) {
|
|
56
|
+
const prisma = new client_1.PrismaClient({
|
|
57
|
+
datasourceUrl: workspaceDatabaseUrl,
|
|
58
|
+
});
|
|
59
|
+
try {
|
|
60
|
+
await prisma.$executeRawUnsafe(`
|
|
61
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "cronjob_runs_one_running_per_cronjob"
|
|
62
|
+
ON "cronjob_runs"("cronjob_id")
|
|
63
|
+
WHERE "status" = 'running';
|
|
64
|
+
`);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await prisma.$disconnect();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
54
70
|
function normalizeRelativePath(relativePath) {
|
|
55
71
|
return relativePath.split(path_1.default.sep).join("/");
|
|
56
72
|
}
|
|
@@ -283,4 +299,5 @@ async function ensureWorkspaceDatabase() {
|
|
|
283
299
|
DATABASE_URL: workspaceDatabaseUrl,
|
|
284
300
|
},
|
|
285
301
|
});
|
|
302
|
+
await repairWorkspaceCronjobRunUniquenessIndex(workspaceDatabaseUrl);
|
|
286
303
|
}
|
package/dist/workflows/index.js
CHANGED
|
@@ -27,6 +27,7 @@ const secrets_1 = require("../utils/secrets");
|
|
|
27
27
|
const secret_contract_validation_1 = require("../utils/secret-contract-validation");
|
|
28
28
|
const workflow_api_keys_1 = require("../utils/workflow-api-keys");
|
|
29
29
|
const external_host_1 = require("../utils/external-host");
|
|
30
|
+
const workflow_run_validation_1 = require("../utils/workflow-run-validation");
|
|
30
31
|
const router = express_1.default.Router({ mergeParams: true });
|
|
31
32
|
const uploadTmpDir = path_1.default.join(os_1.default.tmpdir(), "teamcopilot-workflow-uploads");
|
|
32
33
|
fs_1.default.mkdirSync(uploadTmpDir, { recursive: true });
|
|
@@ -52,25 +53,6 @@ function isPathInside(childPath, parentPath) {
|
|
|
52
53
|
function sanitizeFilenamePart(value) {
|
|
53
54
|
return value.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
54
55
|
}
|
|
55
|
-
async function assertCurrentUserCanRunWorkflow(slug, userId) {
|
|
56
|
-
await (0, workflow_1.readWorkflowManifestAndEnsurePermissions)(slug);
|
|
57
|
-
const approvalState = await (0, workflow_approval_snapshot_1.getWorkflowSnapshotApprovalState)(slug);
|
|
58
|
-
if (!approvalState.is_current_code_approved) {
|
|
59
|
-
throw {
|
|
60
|
-
status: 403,
|
|
61
|
-
message: 'Workflow is not approved for the current code version'
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
const permissionSummary = await (0, resource_access_1.getResourceAccessSummary)("workflow", slug, userId);
|
|
65
|
-
if (!permissionSummary.can_edit) {
|
|
66
|
-
throw {
|
|
67
|
-
status: 403,
|
|
68
|
-
message: permissionSummary.is_locked_due_to_missing_users
|
|
69
|
-
? 'Workflow cannot be run because no allowed users remain'
|
|
70
|
-
: 'You do not have permission to run this workflow. Please contact the workflow owner to request permission.'
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
56
|
async function getWorkflowEditorAccess(slug, userId) {
|
|
75
57
|
const accessSummary = await (0, resource_access_1.getResourceAccessSummary)("workflow", slug, userId);
|
|
76
58
|
const workflowStatus = accessSummary.is_approved ? "approved" : "pending";
|
|
@@ -285,8 +267,7 @@ router.post('/runs/:id/stop', (0, index_1.apiHandler)(async (req, res) => {
|
|
|
285
267
|
message: 'Workflow run session not found'
|
|
286
268
|
};
|
|
287
269
|
}
|
|
288
|
-
|
|
289
|
-
if (isManualSession) {
|
|
270
|
+
if ((0, workflow_interruption_1.usesWorkflowDatabaseAbortMarker)(run.session_id)) {
|
|
290
271
|
await (0, workflow_interruption_1.markWorkflowSessionAborted)(run.session_id);
|
|
291
272
|
}
|
|
292
273
|
else {
|
|
@@ -309,7 +290,7 @@ router.post('/:slug/manual-run', (0, index_1.apiHandler)(async (req, res) => {
|
|
|
309
290
|
const manualMessageId = `manual-message-${(0, crypto_1.randomUUID)()}`;
|
|
310
291
|
const manualCallId = `manual-call-${(0, crypto_1.randomUUID)()}`;
|
|
311
292
|
const workspaceDir = (0, workspace_sync_1.getWorkspaceDirFromEnv)();
|
|
312
|
-
await
|
|
293
|
+
await (0, workflow_run_validation_1.assertUserCanRunWorkflow)(slug, req.userId);
|
|
313
294
|
const startedRun = await (0, workflow_runner_1.startWorkflowRunViaBackend)({
|
|
314
295
|
workspaceDir,
|
|
315
296
|
slug,
|
|
@@ -350,7 +331,25 @@ router.post('/execute', (0, index_1.apiHandler)(async (req, res) => {
|
|
|
350
331
|
const messageId = body.message_id;
|
|
351
332
|
const callId = body.call_id;
|
|
352
333
|
const workspaceDir = (0, workspace_sync_1.getWorkspaceDirFromEnv)();
|
|
353
|
-
await
|
|
334
|
+
await (0, workflow_run_validation_1.assertUserCanRunWorkflow)(slug, req.userId);
|
|
335
|
+
const promptCronjobRun = await client_1.default.cronjob_runs.findFirst({
|
|
336
|
+
where: {
|
|
337
|
+
opencode_session_id: req.opencode_session_id,
|
|
338
|
+
status: "running",
|
|
339
|
+
cronjob: { target_type: "prompt" },
|
|
340
|
+
},
|
|
341
|
+
select: {
|
|
342
|
+
cronjob: {
|
|
343
|
+
select: {
|
|
344
|
+
prompt_allow_workflow_runs_without_permission: true,
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
const requirePermissionPrompt = promptCronjobRun
|
|
350
|
+
? promptCronjobRun.cronjob.prompt_allow_workflow_runs_without_permission !== true
|
|
351
|
+
: true;
|
|
352
|
+
const runSource = promptCronjobRun ? "cronjob" : "user";
|
|
354
353
|
const startedRun = await (0, workflow_runner_1.startWorkflowRunViaBackend)({
|
|
355
354
|
workspaceDir,
|
|
356
355
|
slug,
|
|
@@ -359,8 +358,8 @@ router.post('/execute', (0, index_1.apiHandler)(async (req, res) => {
|
|
|
359
358
|
sessionId: req.opencode_session_id,
|
|
360
359
|
messageId,
|
|
361
360
|
callId,
|
|
362
|
-
requirePermissionPrompt
|
|
363
|
-
runSource
|
|
361
|
+
requirePermissionPrompt,
|
|
362
|
+
runSource,
|
|
364
363
|
secretResolutionMode: "user",
|
|
365
364
|
});
|
|
366
365
|
const executionId = (0, crypto_1.randomUUID)();
|
|
@@ -242,7 +242,7 @@ function collectTrackedPathsForTool(
|
|
|
242
242
|
return extractTrackedPathsFromPatch(patchPayload)
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
-
if (tool === "write") {
|
|
245
|
+
if (tool === "write" || tool === "edit") {
|
|
246
246
|
const filepath =
|
|
247
247
|
findNestedStringByKeys(outputArgs, new Set(["filepath", "filePath"])) ??
|
|
248
248
|
findNestedStringByKeys(inputArgs, new Set(["filepath", "filePath"]))
|
|
@@ -318,7 +318,7 @@ export const ApplyPatchSessionDiffPlugin: Plugin = async ({ client, directory })
|
|
|
318
318
|
|
|
319
319
|
return {
|
|
320
320
|
"tool.execute.before": async (input, output) => {
|
|
321
|
-
if (!["apply_patch", "write", "bash"].includes(input.tool)) {
|
|
321
|
+
if (!["apply_patch", "write", "edit", "bash"].includes(input.tool)) {
|
|
322
322
|
return
|
|
323
323
|
}
|
|
324
324
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { type Plugin, tool } from "@opencode-ai/plugin"
|
|
2
|
+
|
|
3
|
+
function getApiBaseUrl(): string {
|
|
4
|
+
const port = process.env.TEAMCOPILOT_PORT?.trim()
|
|
5
|
+
if (!port) {
|
|
6
|
+
throw new Error("TEAMCOPILOT_PORT must be set.")
|
|
7
|
+
}
|
|
8
|
+
return `http://localhost:${port}`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface SessionLookupResponse {
|
|
12
|
+
error?: unknown
|
|
13
|
+
data?: {
|
|
14
|
+
id?: string
|
|
15
|
+
parentID?: string
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function readErrorMessageFromResponse(
|
|
20
|
+
response: Response,
|
|
21
|
+
fallbackMessage: string
|
|
22
|
+
): Promise<string> {
|
|
23
|
+
try {
|
|
24
|
+
const text = await response.text()
|
|
25
|
+
if (!text) return fallbackMessage
|
|
26
|
+
try {
|
|
27
|
+
const parsed: unknown = JSON.parse(text)
|
|
28
|
+
if (parsed && typeof parsed === "object" && "message" in parsed) {
|
|
29
|
+
const msg = (parsed as { message?: unknown }).message
|
|
30
|
+
if (typeof msg === "string" && msg.trim().length > 0) return msg
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
// fall back to text
|
|
34
|
+
}
|
|
35
|
+
return text.trim().length > 0 ? text : fallbackMessage
|
|
36
|
+
} catch {
|
|
37
|
+
return fallbackMessage
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const AskCronjobUserPlugin: Plugin = async ({ client }) => {
|
|
42
|
+
async function resolveRootSessionID(sessionID: string): Promise<string> {
|
|
43
|
+
let currentSessionID = sessionID
|
|
44
|
+
|
|
45
|
+
while (true) {
|
|
46
|
+
const response = (await client.session.get({
|
|
47
|
+
path: {
|
|
48
|
+
id: currentSessionID,
|
|
49
|
+
},
|
|
50
|
+
})) as SessionLookupResponse
|
|
51
|
+
if (response.error) {
|
|
52
|
+
throw new Error(`Failed to resolve root session for ${currentSessionID}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const parentID = response.data?.parentID
|
|
56
|
+
if (!parentID) {
|
|
57
|
+
return currentSessionID
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
currentSessionID = parentID
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
tool: {
|
|
66
|
+
askCronjobUser: tool({
|
|
67
|
+
description:
|
|
68
|
+
"Ask or notify the user when a TeamCopilot cronjob needs their input or attention. This reveals the hidden cronjob chat to the user and pauses cronjob auto-continue until the user explicitly resumes it.",
|
|
69
|
+
args: {
|
|
70
|
+
message: tool.schema
|
|
71
|
+
.string()
|
|
72
|
+
.describe("The message to show to the user in the cronjob chat."),
|
|
73
|
+
},
|
|
74
|
+
async execute(args, context) {
|
|
75
|
+
const { sessionID } = context
|
|
76
|
+
const authSessionID = await resolveRootSessionID(sessionID)
|
|
77
|
+
const message = args.message?.trim()
|
|
78
|
+
if (!message) {
|
|
79
|
+
throw new Error("message is required")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const response = await fetch(`${getApiBaseUrl()}/api/cronjobs/runs/ask-user-current`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: {
|
|
85
|
+
"Content-Type": "application/json",
|
|
86
|
+
Authorization: `Bearer ${authSessionID}`,
|
|
87
|
+
},
|
|
88
|
+
body: JSON.stringify({ message }),
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
const errorMessage = await readErrorMessageFromResponse(
|
|
93
|
+
response,
|
|
94
|
+
`Failed to ask cronjob user (HTTP ${response.status})`
|
|
95
|
+
)
|
|
96
|
+
throw new Error(errorMessage)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return JSON.stringify({ success: true, message })
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export default AskCronjobUserPlugin
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { type Plugin, tool } from "@opencode-ai/plugin"
|
|
2
|
+
|
|
3
|
+
function getApiBaseUrl(): string {
|
|
4
|
+
const port = process.env.TEAMCOPILOT_PORT?.trim()
|
|
5
|
+
if (!port) {
|
|
6
|
+
throw new Error("TEAMCOPILOT_PORT must be set.")
|
|
7
|
+
}
|
|
8
|
+
return `http://localhost:${port}`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface SessionLookupResponse {
|
|
12
|
+
error?: unknown
|
|
13
|
+
data?: {
|
|
14
|
+
id?: string
|
|
15
|
+
parentID?: string
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function readErrorMessageFromResponse(
|
|
20
|
+
response: Response,
|
|
21
|
+
fallbackMessage: string
|
|
22
|
+
): Promise<string> {
|
|
23
|
+
try {
|
|
24
|
+
const text = await response.text()
|
|
25
|
+
if (!text) return fallbackMessage
|
|
26
|
+
try {
|
|
27
|
+
const parsed: unknown = JSON.parse(text)
|
|
28
|
+
if (parsed && typeof parsed === "object" && "message" in parsed) {
|
|
29
|
+
const msg = (parsed as { message?: unknown }).message
|
|
30
|
+
if (typeof msg === "string" && msg.trim().length > 0) return msg
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
// fall back to text
|
|
34
|
+
}
|
|
35
|
+
return text.trim().length > 0 ? text : fallbackMessage
|
|
36
|
+
} catch {
|
|
37
|
+
return fallbackMessage
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function postJson(path: string, authSessionID: string, body: unknown): Promise<unknown> {
|
|
42
|
+
const response = await fetch(`${getApiBaseUrl()}${path}`, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: {
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
Authorization: `Bearer ${authSessionID}`,
|
|
47
|
+
},
|
|
48
|
+
body: JSON.stringify(body),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
const errorMessage = await readErrorMessageFromResponse(
|
|
53
|
+
response,
|
|
54
|
+
`Cronjob todo tool failed (HTTP ${response.status})`
|
|
55
|
+
)
|
|
56
|
+
throw new Error(errorMessage)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return response.json()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function getJson(path: string, authSessionID: string): Promise<unknown> {
|
|
63
|
+
const response = await fetch(`${getApiBaseUrl()}${path}`, {
|
|
64
|
+
headers: {
|
|
65
|
+
Authorization: `Bearer ${authSessionID}`,
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
const errorMessage = await readErrorMessageFromResponse(
|
|
71
|
+
response,
|
|
72
|
+
`Cronjob todo tool failed (HTTP ${response.status})`
|
|
73
|
+
)
|
|
74
|
+
throw new Error(errorMessage)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return response.json()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const ManageCronjobTodosPlugin: Plugin = async ({ client }) => {
|
|
81
|
+
async function resolveRootSessionID(sessionID: string): Promise<string> {
|
|
82
|
+
let currentSessionID = sessionID
|
|
83
|
+
|
|
84
|
+
while (true) {
|
|
85
|
+
const response = (await client.session.get({
|
|
86
|
+
path: {
|
|
87
|
+
id: currentSessionID,
|
|
88
|
+
},
|
|
89
|
+
})) as SessionLookupResponse
|
|
90
|
+
if (response.error) {
|
|
91
|
+
throw new Error(`Failed to resolve root session for ${currentSessionID}`)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const parentID = response.data?.parentID
|
|
95
|
+
if (!parentID) {
|
|
96
|
+
return currentSessionID
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
currentSessionID = parentID
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
tool: {
|
|
105
|
+
addCronjobTodos: tool({
|
|
106
|
+
description:
|
|
107
|
+
"Add new todo items to the active TeamCopilot cronjob todo list. You must always pass an index and the latest todo_list_version from getCronjobTodos. Example: if the active list is [A, B, C], then addCronjobTodos({ items: [\"X\", \"Y\"], index: 1, todo_list_version: 7 }) produces [A, X, Y, B, C] when the snapshot is still version 7. If you want to insert at the start of the list, use index 0. If you want to append to the end of the active list, pass index equal to the current active todo list length. If you pass an index that is greater than the current active todo list length, or a stale todo_list_version, the tool fails and returns the current todo list so you can refresh your snapshot. To make sure that you use the right index and version, always call getCronjobTodos right before calling this tool.",
|
|
108
|
+
args: {
|
|
109
|
+
items: tool.schema
|
|
110
|
+
.array(tool.schema.string())
|
|
111
|
+
.describe("New todo items required to complete the cronjob task."),
|
|
112
|
+
index: tool.schema
|
|
113
|
+
.number()
|
|
114
|
+
.describe("Required insertion index in the active todo list."),
|
|
115
|
+
todo_list_version: tool.schema
|
|
116
|
+
.number()
|
|
117
|
+
.describe("Required current todo snapshot version from the most recent getCronjobTodos call."),
|
|
118
|
+
},
|
|
119
|
+
async execute(args, context) {
|
|
120
|
+
const { sessionID } = context
|
|
121
|
+
const authSessionID = await resolveRootSessionID(sessionID)
|
|
122
|
+
return JSON.stringify(await postJson("/api/cronjobs/runs/todos/add", authSessionID, {
|
|
123
|
+
items: args.items,
|
|
124
|
+
index: args.index,
|
|
125
|
+
todo_list_version: args.todo_list_version,
|
|
126
|
+
}))
|
|
127
|
+
},
|
|
128
|
+
}),
|
|
129
|
+
clearCronjobTodos: tool({
|
|
130
|
+
description:
|
|
131
|
+
"Remove one or more todos from the active TeamCopilot cronjob todo list. Provide todo_ids only. Todo ids are the stable references returned by the todo tools.",
|
|
132
|
+
args: {
|
|
133
|
+
todo_ids: tool.schema
|
|
134
|
+
.array(tool.schema.string())
|
|
135
|
+
.optional()
|
|
136
|
+
.describe("Todo ids to remove from the active list."),
|
|
137
|
+
},
|
|
138
|
+
async execute(args, context) {
|
|
139
|
+
const { sessionID } = context
|
|
140
|
+
const authSessionID = await resolveRootSessionID(sessionID)
|
|
141
|
+
return JSON.stringify(await postJson("/api/cronjobs/runs/todos/clear", authSessionID, args))
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
getCurrentCronjobTodo: tool({
|
|
145
|
+
description:
|
|
146
|
+
"Get the current TeamCopilot cronjob todo item, if one is active.",
|
|
147
|
+
args: {},
|
|
148
|
+
async execute(_args, context) {
|
|
149
|
+
const { sessionID } = context
|
|
150
|
+
const authSessionID = await resolveRootSessionID(sessionID)
|
|
151
|
+
const response = await getJson("/api/cronjobs/runs/todos/not-completed", authSessionID) as { todos?: Array<{ status?: string }> }
|
|
152
|
+
const currentTodo = response.todos?.find((todo) => todo.status === "in_progress") ?? null
|
|
153
|
+
return JSON.stringify({ todo: currentTodo })
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
getCronjobTodos: tool({
|
|
157
|
+
description:
|
|
158
|
+
"Get all active TeamCopilot cronjob todos that are not completed yet, including the current todo and any pending todos. This also returns the todo_list_version you must pass back to addCronjobTodos.",
|
|
159
|
+
args: {},
|
|
160
|
+
async execute(_args, context) {
|
|
161
|
+
const { sessionID } = context
|
|
162
|
+
const authSessionID = await resolveRootSessionID(sessionID)
|
|
163
|
+
return JSON.stringify(await getJson("/api/cronjobs/runs/todos/not-completed", authSessionID))
|
|
164
|
+
},
|
|
165
|
+
}),
|
|
166
|
+
finishCurrentCronjobTodo: tool({
|
|
167
|
+
description:
|
|
168
|
+
"Mark the current TeamCopilot cronjob todo item complete. Use this only after the current todo item is fully done, then stop and wait for TeamCopilot to give you the next todo.",
|
|
169
|
+
args: {
|
|
170
|
+
completionSummary: tool.schema
|
|
171
|
+
.string()
|
|
172
|
+
.describe("Concise completion summary for what was completed for the current todo item."),
|
|
173
|
+
},
|
|
174
|
+
async execute(args, context) {
|
|
175
|
+
const { sessionID } = context
|
|
176
|
+
const authSessionID = await resolveRootSessionID(sessionID)
|
|
177
|
+
const completionSummary = args.completionSummary?.trim()
|
|
178
|
+
if (!completionSummary) {
|
|
179
|
+
throw new Error("completionSummary is required")
|
|
180
|
+
}
|
|
181
|
+
return JSON.stringify(await postJson("/api/cronjobs/runs/todos/finish-current", authSessionID, {
|
|
182
|
+
completionSummary,
|
|
183
|
+
}))
|
|
184
|
+
},
|
|
185
|
+
}),
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export default ManageCronjobTodosPlugin
|