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
|
@@ -0,0 +1,376 @@
|
|
|
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 PermissionResponse {
|
|
12
|
+
approved: boolean
|
|
13
|
+
status: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface SessionLookupResponse {
|
|
17
|
+
error?: unknown
|
|
18
|
+
data?: {
|
|
19
|
+
id?: string
|
|
20
|
+
parentID?: string
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function readErrorMessageFromResponse(
|
|
25
|
+
response: Response,
|
|
26
|
+
fallbackMessage: string
|
|
27
|
+
): Promise<string> {
|
|
28
|
+
try {
|
|
29
|
+
const text = await response.text()
|
|
30
|
+
if (!text) return fallbackMessage
|
|
31
|
+
try {
|
|
32
|
+
const parsed: unknown = JSON.parse(text)
|
|
33
|
+
if (parsed && typeof parsed === "object" && "message" in parsed) {
|
|
34
|
+
const msg = (parsed as { message?: unknown }).message
|
|
35
|
+
if (typeof msg === "string" && msg.trim().length > 0) return msg
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// fall back to text
|
|
39
|
+
}
|
|
40
|
+
return text.trim().length > 0 ? text : fallbackMessage
|
|
41
|
+
} catch {
|
|
42
|
+
return fallbackMessage
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function extractMessageId(context: unknown): string | null {
|
|
47
|
+
if (!context || typeof context !== "object") {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const candidate = (context as { messageID?: unknown; messageId?: unknown; message_id?: unknown })
|
|
52
|
+
const raw = candidate.messageID ?? candidate.messageId ?? candidate.message_id
|
|
53
|
+
if (typeof raw === "string" && raw.trim().length > 0) {
|
|
54
|
+
return raw
|
|
55
|
+
}
|
|
56
|
+
if (typeof raw === "number" && Number.isFinite(raw)) {
|
|
57
|
+
return String(raw)
|
|
58
|
+
}
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function extractCallId(context: unknown): string | null {
|
|
63
|
+
if (!context || typeof context !== "object") {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const candidate = (context as { callID?: unknown; callId?: unknown; call_id?: unknown })
|
|
68
|
+
const raw = candidate.callID ?? candidate.callId ?? candidate.call_id
|
|
69
|
+
if (typeof raw === "string" && raw.trim().length > 0) {
|
|
70
|
+
return raw
|
|
71
|
+
}
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function assertTargetType(targetType: string): void {
|
|
76
|
+
if (targetType !== "prompt" && targetType !== "workflow") {
|
|
77
|
+
throw new Error('target_type must be "prompt" or "workflow".')
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function assertNonEmpty(value: string, label: string): void {
|
|
82
|
+
if (value.trim().length === 0) {
|
|
83
|
+
throw new Error(`${label} is required.`)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function ensureMessageAndCallIds(context: unknown): { messageId: string; callId: string } {
|
|
88
|
+
const messageId = extractMessageId(context)
|
|
89
|
+
const callId = extractCallId(context)
|
|
90
|
+
|
|
91
|
+
if (!messageId) {
|
|
92
|
+
throw new Error("Could not determine message id from tool context.")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!callId) {
|
|
96
|
+
throw new Error("Could not determine call id from tool context.")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { messageId, callId }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function rejectCronjobPermission(
|
|
103
|
+
sessionID: string,
|
|
104
|
+
permissionId: string
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
await fetch(`${getApiBaseUrl()}/api/workflows/permission-reject/${permissionId}`, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: {
|
|
109
|
+
Authorization: `Bearer ${sessionID}`,
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function requestCronjobPermission(
|
|
115
|
+
sessionID: string,
|
|
116
|
+
messageID: string,
|
|
117
|
+
callID: string,
|
|
118
|
+
action: string
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
const response = await fetch(`${getApiBaseUrl()}/api/workflows/request-permission`, {
|
|
121
|
+
method: "POST",
|
|
122
|
+
headers: {
|
|
123
|
+
"Content-Type": "application/json",
|
|
124
|
+
Authorization: `Bearer ${sessionID}`,
|
|
125
|
+
},
|
|
126
|
+
body: JSON.stringify({
|
|
127
|
+
message_id: messageID,
|
|
128
|
+
call_id: callID,
|
|
129
|
+
}),
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
const message = await readErrorMessageFromResponse(
|
|
134
|
+
response,
|
|
135
|
+
`Failed to request permission (HTTP ${response.status})`
|
|
136
|
+
)
|
|
137
|
+
throw new Error(message)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const data = (await response.json()) as { permission_id: string }
|
|
141
|
+
const permissionId = data.permission_id
|
|
142
|
+
|
|
143
|
+
const maxAttempts = 300
|
|
144
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
145
|
+
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
146
|
+
|
|
147
|
+
const statusResponse = await fetch(
|
|
148
|
+
`${getApiBaseUrl()}/api/workflows/permission-status/${permissionId}`,
|
|
149
|
+
{
|
|
150
|
+
headers: {
|
|
151
|
+
Authorization: `Bearer ${sessionID}`,
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if (!statusResponse.ok) {
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const statusData = (await statusResponse.json()) as PermissionResponse
|
|
161
|
+
|
|
162
|
+
if (statusData.status === "approved") {
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
if (statusData.status === "rejected") {
|
|
166
|
+
throw new Error(`User denied permission to ${action}.`)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
await rejectCronjobPermission(sessionID, permissionId)
|
|
172
|
+
} catch {
|
|
173
|
+
// Best-effort cleanup only; preserve the timeout error below.
|
|
174
|
+
}
|
|
175
|
+
throw new Error("Permission request timed out")
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function fetchJsonWithError(
|
|
179
|
+
url: string,
|
|
180
|
+
init: RequestInit,
|
|
181
|
+
fallbackMessage: string
|
|
182
|
+
): Promise<unknown> {
|
|
183
|
+
const response = await fetch(url, init)
|
|
184
|
+
if (!response.ok) {
|
|
185
|
+
const errorMessage = await readErrorMessageFromResponse(response, fallbackMessage)
|
|
186
|
+
throw new Error(errorMessage)
|
|
187
|
+
}
|
|
188
|
+
return await response.json()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export const ManageCronjobsPlugin: Plugin = async ({ client }) => {
|
|
192
|
+
async function resolveRootSessionID(sessionID: string): Promise<string> {
|
|
193
|
+
let currentSessionID = sessionID
|
|
194
|
+
|
|
195
|
+
while (true) {
|
|
196
|
+
const response = (await client.session.get({
|
|
197
|
+
path: {
|
|
198
|
+
id: currentSessionID,
|
|
199
|
+
},
|
|
200
|
+
})) as SessionLookupResponse
|
|
201
|
+
if (response.error) {
|
|
202
|
+
throw new Error(`Failed to resolve root session for ${currentSessionID}`)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const parentID = response.data?.parentID
|
|
206
|
+
if (!parentID) {
|
|
207
|
+
return currentSessionID
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
currentSessionID = parentID
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
tool: {
|
|
216
|
+
listCronjobs: tool({
|
|
217
|
+
description:
|
|
218
|
+
"List the current user's TeamCopilot cronjobs, including ids needed for editCronjob and runCronjobNow. This is read-only and does not require approval.",
|
|
219
|
+
args: {},
|
|
220
|
+
async execute(_args, context) {
|
|
221
|
+
const { sessionID } = context
|
|
222
|
+
const authSessionID = await resolveRootSessionID(sessionID)
|
|
223
|
+
|
|
224
|
+
const payload = await fetchJsonWithError(
|
|
225
|
+
`${getApiBaseUrl()}/api/cronjobs`,
|
|
226
|
+
{
|
|
227
|
+
method: "GET",
|
|
228
|
+
headers: {
|
|
229
|
+
Authorization: `Bearer ${authSessionID}`,
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
"Failed to list cronjobs"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return JSON.stringify(payload, null, 2)
|
|
236
|
+
},
|
|
237
|
+
}),
|
|
238
|
+
createCronjob: tool({
|
|
239
|
+
description:
|
|
240
|
+
"Create and schedule a TeamCopilot cronjob. Requires user approval for this tool call. Future scheduled runs of the created cronjob will not ask for approval again.",
|
|
241
|
+
args: {
|
|
242
|
+
name: tool.schema.string().describe("Human-readable cronjob name."),
|
|
243
|
+
enabled: tool.schema.boolean().describe("Whether the cronjob should be scheduled immediately after creation."),
|
|
244
|
+
target_type: tool.schema.enum(["prompt", "workflow"]).describe("Use prompt for an agent prompt cronjob, or workflow for a direct workflow cronjob."),
|
|
245
|
+
prompt: tool.schema.string().optional().describe("Required when target_type is prompt. The unattended cronjob prompt to run."),
|
|
246
|
+
allow_workflow_runs_without_permission: tool.schema.boolean().optional().default(true).describe("Prompt cronjobs only. If true, workflows invoked by that cronjob run without additional user approval during scheduled execution."),
|
|
247
|
+
workflow_slug: tool.schema.string().optional().describe("Required when target_type is workflow. Workflow slug to run."),
|
|
248
|
+
workflow_inputs: tool.schema.record(tool.schema.string(), tool.schema.unknown()).optional().default({}).describe("Workflow cronjobs only. Saved workflow input arguments."),
|
|
249
|
+
cron_expression: tool.schema.string().describe("Five-field cron expression, for example '0 9 * * 1-5'."),
|
|
250
|
+
timezone: tool.schema.string().describe("IANA timezone, for example 'UTC', 'Asia/Kolkata', or 'America/Los_Angeles'."),
|
|
251
|
+
},
|
|
252
|
+
async execute(args, context) {
|
|
253
|
+
const { sessionID } = context
|
|
254
|
+
const authSessionID = await resolveRootSessionID(sessionID)
|
|
255
|
+
const { messageId, callId } = ensureMessageAndCallIds(context)
|
|
256
|
+
|
|
257
|
+
assertNonEmpty(args.name, "name")
|
|
258
|
+
assertTargetType(args.target_type)
|
|
259
|
+
assertNonEmpty(args.cron_expression, "cron_expression")
|
|
260
|
+
assertNonEmpty(args.timezone, "timezone")
|
|
261
|
+
if (args.target_type === "prompt") {
|
|
262
|
+
assertNonEmpty(args.prompt ?? "", "prompt")
|
|
263
|
+
}
|
|
264
|
+
if (args.target_type === "workflow") {
|
|
265
|
+
assertNonEmpty(args.workflow_slug ?? "", "workflow_slug")
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
await requestCronjobPermission(authSessionID, messageId, callId, "create this cronjob")
|
|
269
|
+
|
|
270
|
+
const payload = await fetchJsonWithError(
|
|
271
|
+
`${getApiBaseUrl()}/api/cronjobs`,
|
|
272
|
+
{
|
|
273
|
+
method: "POST",
|
|
274
|
+
headers: {
|
|
275
|
+
"Content-Type": "application/json",
|
|
276
|
+
Authorization: `Bearer ${authSessionID}`,
|
|
277
|
+
},
|
|
278
|
+
body: JSON.stringify(args),
|
|
279
|
+
},
|
|
280
|
+
"Failed to create cronjob"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return JSON.stringify(payload, null, 2)
|
|
284
|
+
},
|
|
285
|
+
}),
|
|
286
|
+
editCronjob: tool({
|
|
287
|
+
description:
|
|
288
|
+
"Edit an existing TeamCopilot cronjob. Requires user approval for this tool call. Future scheduled runs of the edited cronjob will not ask for approval again.",
|
|
289
|
+
args: {
|
|
290
|
+
id: tool.schema.string().describe("Cronjob id to edit."),
|
|
291
|
+
name: tool.schema.string().optional().describe("New human-readable cronjob name."),
|
|
292
|
+
enabled: tool.schema.boolean().optional().describe("Whether the cronjob should be scheduled."),
|
|
293
|
+
target_type: tool.schema.enum(["prompt", "workflow"]).optional().describe("Use prompt for an agent prompt cronjob, or workflow for a direct workflow cronjob."),
|
|
294
|
+
prompt: tool.schema.string().optional().describe("Prompt cronjob task text."),
|
|
295
|
+
allow_workflow_runs_without_permission: tool.schema.boolean().optional().describe("Prompt cronjobs only. If true, workflows invoked by that cronjob run without additional user approval during scheduled execution."),
|
|
296
|
+
workflow_slug: tool.schema.string().optional().describe("Workflow slug for workflow cronjobs."),
|
|
297
|
+
workflow_inputs: tool.schema.record(tool.schema.string(), tool.schema.unknown()).optional().describe("Saved workflow input arguments."),
|
|
298
|
+
cron_expression: tool.schema.string().optional().describe("Five-field cron expression, for example '0 9 * * 1-5'."),
|
|
299
|
+
timezone: tool.schema.string().optional().describe("IANA timezone, for example 'UTC', 'Asia/Kolkata', or 'America/Los_Angeles'."),
|
|
300
|
+
},
|
|
301
|
+
async execute(args, context) {
|
|
302
|
+
const { sessionID } = context
|
|
303
|
+
const authSessionID = await resolveRootSessionID(sessionID)
|
|
304
|
+
const { messageId, callId } = ensureMessageAndCallIds(context)
|
|
305
|
+
|
|
306
|
+
assertNonEmpty(args.id, "id")
|
|
307
|
+
if (args.target_type !== undefined) {
|
|
308
|
+
assertTargetType(args.target_type)
|
|
309
|
+
}
|
|
310
|
+
if (args.name !== undefined) {
|
|
311
|
+
assertNonEmpty(args.name, "name")
|
|
312
|
+
}
|
|
313
|
+
if (args.cron_expression !== undefined) {
|
|
314
|
+
assertNonEmpty(args.cron_expression, "cron_expression")
|
|
315
|
+
}
|
|
316
|
+
if (args.timezone !== undefined) {
|
|
317
|
+
assertNonEmpty(args.timezone, "timezone")
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const { id, ...patch } = args
|
|
321
|
+
if (Object.keys(patch).length === 0) {
|
|
322
|
+
throw new Error("At least one cronjob field must be provided to edit.")
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
await requestCronjobPermission(authSessionID, messageId, callId, "edit this cronjob")
|
|
326
|
+
|
|
327
|
+
const payload = await fetchJsonWithError(
|
|
328
|
+
`${getApiBaseUrl()}/api/cronjobs/${encodeURIComponent(id)}`,
|
|
329
|
+
{
|
|
330
|
+
method: "PATCH",
|
|
331
|
+
headers: {
|
|
332
|
+
"Content-Type": "application/json",
|
|
333
|
+
Authorization: `Bearer ${authSessionID}`,
|
|
334
|
+
},
|
|
335
|
+
body: JSON.stringify(patch),
|
|
336
|
+
},
|
|
337
|
+
"Failed to edit cronjob"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
return JSON.stringify(payload, null, 2)
|
|
341
|
+
},
|
|
342
|
+
}),
|
|
343
|
+
runCronjobNow: tool({
|
|
344
|
+
description:
|
|
345
|
+
"Run an existing TeamCopilot cronjob immediately. Requires user approval for this tool call.",
|
|
346
|
+
args: {
|
|
347
|
+
id: tool.schema.string().describe("Cronjob id to run now."),
|
|
348
|
+
},
|
|
349
|
+
async execute(args, context) {
|
|
350
|
+
const { sessionID } = context
|
|
351
|
+
const authSessionID = await resolveRootSessionID(sessionID)
|
|
352
|
+
const { messageId, callId } = ensureMessageAndCallIds(context)
|
|
353
|
+
|
|
354
|
+
assertNonEmpty(args.id, "id")
|
|
355
|
+
|
|
356
|
+
await requestCronjobPermission(authSessionID, messageId, callId, "run this cronjob now")
|
|
357
|
+
|
|
358
|
+
const payload = await fetchJsonWithError(
|
|
359
|
+
`${getApiBaseUrl()}/api/cronjobs/${encodeURIComponent(args.id)}/run-now`,
|
|
360
|
+
{
|
|
361
|
+
method: "POST",
|
|
362
|
+
headers: {
|
|
363
|
+
Authorization: `Bearer ${authSessionID}`,
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
"Failed to run cronjob now"
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
return JSON.stringify(payload, null, 2)
|
|
370
|
+
},
|
|
371
|
+
}),
|
|
372
|
+
},
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export default ManageCronjobsPlugin
|
|
@@ -0,0 +1,107 @@
|
|
|
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 MarkCronjobCompletedPlugin: 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
|
+
markCronjobCompleted: tool({
|
|
67
|
+
description:
|
|
68
|
+
"Mark the current TeamCopilot cronjob run as successfully completed. Use this exactly once, only after all requested cronjob work is complete and all TeamCopilot cronjob todos have been finished. This tool fails while any cronjob todo is still pending or in progress.",
|
|
69
|
+
args: {
|
|
70
|
+
summary: tool.schema
|
|
71
|
+
.string()
|
|
72
|
+
.describe("A concise summary of what the cronjob completed, suitable for the cronjob run history."),
|
|
73
|
+
},
|
|
74
|
+
async execute(args, context) {
|
|
75
|
+
const { sessionID } = context
|
|
76
|
+
const authSessionID = await resolveRootSessionID(sessionID)
|
|
77
|
+
const summary = args.summary?.trim()
|
|
78
|
+
|
|
79
|
+
if (!summary) {
|
|
80
|
+
throw new Error("summary is required")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const response = await fetch(`${getApiBaseUrl()}/api/cronjobs/runs/complete-current`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: {
|
|
86
|
+
"Content-Type": "application/json",
|
|
87
|
+
Authorization: `Bearer ${authSessionID}`,
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify({ summary }),
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
const errorMessage = await readErrorMessageFromResponse(
|
|
94
|
+
response,
|
|
95
|
+
`Failed to mark cronjob completed (HTTP ${response.status})`
|
|
96
|
+
)
|
|
97
|
+
throw new Error(errorMessage)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return JSON.stringify({ success: true, summary })
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export default MarkCronjobCompletedPlugin
|
|
@@ -0,0 +1,107 @@
|
|
|
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 MarkCronjobFailedPlugin: 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
|
+
markCronjobFailed: tool({
|
|
67
|
+
description:
|
|
68
|
+
"Mark the current TeamCopilot cronjob run as failed. Use this exactly once when the cronjob task cannot be completed because of a non-recoverable issue, and include a concise reason suitable for cronjob run history.",
|
|
69
|
+
args: {
|
|
70
|
+
summary: tool.schema
|
|
71
|
+
.string()
|
|
72
|
+
.describe("A concise reason explaining why the cronjob could not complete."),
|
|
73
|
+
},
|
|
74
|
+
async execute(args, context) {
|
|
75
|
+
const { sessionID } = context
|
|
76
|
+
const authSessionID = await resolveRootSessionID(sessionID)
|
|
77
|
+
const summary = args.summary?.trim()
|
|
78
|
+
|
|
79
|
+
if (!summary) {
|
|
80
|
+
throw new Error("summary is required")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const response = await fetch(`${getApiBaseUrl()}/api/cronjobs/runs/fail-current`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: {
|
|
86
|
+
"Content-Type": "application/json",
|
|
87
|
+
Authorization: `Bearer ${authSessionID}`,
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify({ summary }),
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
const errorMessage = await readErrorMessageFromResponse(
|
|
94
|
+
response,
|
|
95
|
+
`Failed to mark cronjob failed (HTTP ${response.status})`
|
|
96
|
+
)
|
|
97
|
+
throw new Error(errorMessage)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return JSON.stringify({ success: true, summary })
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export default MarkCronjobFailedPlugin
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# TeamCopilot Agent Instructions
|
|
2
2
|
|
|
3
|
-
You are TeamCopilot agent.
|
|
3
|
+
You are TeamCopilot agent.
|
|
4
4
|
|
|
5
5
|
TeamCopilot is a multi-user AI agent platform for teams that want shared agent capabilities with permissions. Users can create custom agent skills and workflows (python scripts), and share them with members of their team in a secure and controlled environment. This document is your operating manual for working within this directory (called workspace). Follow these conventions strictly when creating, updating, or running workflows and custom skills.
|
|
6
6
|
|
|
@@ -531,6 +531,56 @@ When you need a quick inventory before semantic search or selection:
|
|
|
531
531
|
2. If needed, then use `findSimilarWorkflow` to rank by semantic relevance.
|
|
532
532
|
3. Choose the best candidate and proceed with `runWorkflow` (or update/create flow as needed).
|
|
533
533
|
|
|
534
|
+
---
|
|
535
|
+
|
|
536
|
+
## Section 3: Cronjobs
|
|
537
|
+
|
|
538
|
+
### What is a Cronjob?
|
|
539
|
+
|
|
540
|
+
A **cronjob** is a scheduled TeamCopilot task owned by the current user. Cronjobs can run either:
|
|
541
|
+
- A prompt-based agent session on a recurring schedule
|
|
542
|
+
- A workflow directly with saved workflow input arguments
|
|
543
|
+
|
|
544
|
+
Cronjobs are configured through platform APIs via dedicated cronjob tools. Do not create, edit, or run cronjobs with raw HTTP calls, direct database writes, or shell scripts.
|
|
545
|
+
|
|
546
|
+
### Required Cronjob Tools
|
|
547
|
+
|
|
548
|
+
- `listCronjobs` — list the current user's cronjobs. Use this to find cronjob ids before editing or running an existing cronjob.
|
|
549
|
+
- `createCronjob` — create and schedule a cronjob. This tool requires explicit user permission during execution.
|
|
550
|
+
- `editCronjob` — edit an existing cronjob. This tool requires explicit user permission during execution.
|
|
551
|
+
- `runCronjobNow` — run an existing cronjob immediately. This tool requires explicit user permission during execution.
|
|
552
|
+
|
|
553
|
+
### Cronjob Rules
|
|
554
|
+
|
|
555
|
+
- Use cronjob tools when the user asks you to schedule recurring work.
|
|
556
|
+
- After a cronjob is created or edited, its future scheduled runs are already authorized by that approved tool call. Do not ask the user to approve each future scheduled run.
|
|
557
|
+
- If the user asks to delete a cronjob, tell them that you cannot delete it and that they should delete it using the UI. You can only disable it if they want to.
|
|
558
|
+
- For workflow cronjobs, provide all required workflow input arguments during creation or editing.
|
|
559
|
+
- For prompt cronjobs, write prompts so the scheduled agent can finish without user input unless user attention is truly required.
|
|
560
|
+
- In prompt cronjobs, unless it's a very simple task, prefer creating a custom skill first, and then mentioning to use that skill in the prompt. We want to keep the cronjob prompt as small as possible.
|
|
561
|
+
|
|
562
|
+
### Example: Creating a Cronjob
|
|
563
|
+
|
|
564
|
+
When asked to "Schedule a repo health check every weekday at 9 AM":
|
|
565
|
+
|
|
566
|
+
1. Use `createCronjob` with:
|
|
567
|
+
- `name`
|
|
568
|
+
- `enabled`
|
|
569
|
+
- `target_type`
|
|
570
|
+
- `prompt` or `workflow_slug` plus `workflow_inputs`
|
|
571
|
+
- `cron_expression`
|
|
572
|
+
- `timezone`
|
|
573
|
+
2. Wait for the user to approve the tool permission prompt.
|
|
574
|
+
3. Tell the user that future scheduled runs will not ask for approval again.
|
|
575
|
+
|
|
576
|
+
### Example: Editing or Running a Cronjob
|
|
577
|
+
|
|
578
|
+
When asked to change or immediately run an existing cronjob:
|
|
579
|
+
|
|
580
|
+
1. Use `listCronjobs` to find the correct cronjob id.
|
|
581
|
+
2. Use `editCronjob` or `runCronjobNow`.
|
|
582
|
+
3. Wait for the user to approve the tool permission prompt.
|
|
583
|
+
|
|
534
584
|
## Example: Finding and Using a Skill
|
|
535
585
|
|
|
536
586
|
When a user asks for behavior that may already be captured as reusable instructions:
|