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.
Files changed (65) hide show
  1. package/dist/chat/index.js +104 -81
  2. package/dist/cronjob/index.js +2 -0
  3. package/dist/cronjobs/index.js +822 -0
  4. package/dist/cronjobs/scheduler.js +936 -0
  5. package/dist/frontend/assets/{cssMode-BRVRAYCz.js → cssMode-Cqdl5sUM.js} +1 -1
  6. package/dist/frontend/assets/{freemarker2-B5FvHwsO.js → freemarker2-ykAhuplU.js} +1 -1
  7. package/dist/frontend/assets/{handlebars-DWX2asql.js → handlebars-DX_JwRM8.js} +1 -1
  8. package/dist/frontend/assets/{html-BEBxxD9G.js → html-Bi_zOcbU.js} +1 -1
  9. package/dist/frontend/assets/{htmlMode-B2LbPTwC.js → htmlMode-CkAUoAah.js} +1 -1
  10. package/dist/frontend/assets/index-Ba9bElZm.css +1 -0
  11. package/dist/frontend/assets/{index-D3TE04C5.js → index-Cgozj4fx.js} +245 -242
  12. package/dist/frontend/assets/{javascript-Bh4JwoPV.js → javascript-D3Rjwp97.js} +1 -1
  13. package/dist/frontend/assets/{jsonMode-7j-aplXT.js → jsonMode-K4i6LjP2.js} +1 -1
  14. package/dist/frontend/assets/{liquid-BP4OxkO7.js → liquid-D8F4-sAz.js} +1 -1
  15. package/dist/frontend/assets/{mdx-C1OIcGbY.js → mdx-C2xw8PNz.js} +1 -1
  16. package/dist/frontend/assets/{python-BO8Wy5jz.js → python-CqTGfu2v.js} +1 -1
  17. package/dist/frontend/assets/{razor-BDtqXvAH.js → razor-DFSsPzdZ.js} +1 -1
  18. package/dist/frontend/assets/{tsMode-D22HcCuX.js → tsMode-BkLQEtPb.js} +1 -1
  19. package/dist/frontend/assets/{typescript-CagwEzRw.js → typescript-CE_GQ-M1.js} +1 -1
  20. package/dist/frontend/assets/{xml-fE5sGZ5z.js → xml-CGjMtNcA.js} +1 -1
  21. package/dist/frontend/assets/{yaml-CZMoG4WG.js → yaml-Zju9kuFB.js} +1 -1
  22. package/dist/frontend/index.html +2 -2
  23. package/dist/index.js +3 -0
  24. package/dist/types/cronjob.js +2 -0
  25. package/dist/utils/chat-prompt-context.js +65 -0
  26. package/dist/utils/chat-session.js +12 -0
  27. package/dist/utils/index.js +27 -0
  28. package/dist/utils/workflow-interruption.js +5 -2
  29. package/dist/utils/workflow-run-validation.js +25 -0
  30. package/dist/utils/workspace-sync.js +17 -0
  31. package/dist/workflows/index.js +24 -25
  32. package/dist/workspace_files/.opencode/plugins/apply-patch-session-diff.ts +2 -2
  33. package/dist/workspace_files/.opencode/plugins/askCronjobUser.ts +106 -0
  34. package/dist/workspace_files/.opencode/plugins/manageCronjobTodos.ts +190 -0
  35. package/dist/workspace_files/.opencode/plugins/manageCronjobs.ts +376 -0
  36. package/dist/workspace_files/.opencode/plugins/markCronjobCompleted.ts +107 -0
  37. package/dist/workspace_files/.opencode/plugins/markCronjobFailed.ts +107 -0
  38. package/dist/workspace_files/AGENTS.md +51 -1
  39. package/package.json +1 -1
  40. package/prisma/generated/client/edge.js +50 -3
  41. package/prisma/generated/client/index-browser.js +47 -0
  42. package/prisma/generated/client/index.d.ts +13918 -7530
  43. package/prisma/generated/client/index.js +50 -3
  44. package/prisma/generated/client/package.json +1 -1
  45. package/prisma/generated/client/schema.prisma +72 -1
  46. package/prisma/generated/client/wasm.js +50 -3
  47. package/prisma/migrations/20260508050030_add_cronjobs/migration.sql +78 -0
  48. package/prisma/migrations/20260508093158_add_structured_cronjob_schedules/migration.sql +23 -0
  49. package/prisma/migrations/20260508105129_add_cronjob_targets/migration.sql +50 -0
  50. package/prisma/migrations/20260509044545_flatten_cronjob_schema/migration.sql +88 -0
  51. package/prisma/migrations/20260509052232_simplify_cronjob_schedule_storage/migration.sql +42 -0
  52. package/prisma/migrations/20260509054000_remove_chat_session_source_add_cronjob_run_indexes/migration.sql +28 -0
  53. package/prisma/migrations/20260509061000_cascade_cronjob_run_links/migration.sql +29 -0
  54. package/prisma/migrations/20260513073541_add_cronjob_run_todos/migration.sql +18 -0
  55. package/prisma/migrations/20260513133021_add_cronjob_user_response_wait/migration.sql +29 -0
  56. package/prisma/migrations/20260513135733_add_cronjob_user_handoff_state/migration.sql +30 -0
  57. package/prisma/migrations/20260513142511_drop_awaiting_user_response/migration.sql +35 -0
  58. package/prisma/migrations/20260514032204_simplify_cronjob_run_lifecycle/migration.sql +34 -0
  59. package/prisma/migrations/20260514043000_clear_cronjob_run_history/migration.sql +6 -0
  60. package/prisma/migrations/20260515094618_add_todo_list_version/migration.sql +32 -0
  61. package/prisma/migrations/20260516082714_add_cronjob_monitor_timeout/migration.sql +38 -0
  62. package/prisma/migrations/20260516083452_allow_decimal_cronjob_timeout/migration.sql +37 -0
  63. package/prisma/migrations/20260516084455_add_cronjob_timeout_defaults/migration.sql +31 -0
  64. package/prisma/schema.prisma +71 -1
  65. 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) {
@@ -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
- const usesDatabaseAbortMarker = sessionId.startsWith("manual-") || sessionId.startsWith("api-");
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
  }
@@ -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
- const isManualSession = run.session_id.startsWith("manual-");
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 assertCurrentUserCanRunWorkflow(slug, req.userId);
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 assertCurrentUserCanRunWorkflow(slug, req.userId);
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: true,
363
- runSource: "user",
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