@saadnvd1/agent-os 0.3.792 → 0.3.799
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/app/api/busy/route.ts +2 -2
- package/app/api/tips/route.test.ts +56 -0
- package/app/api/tips/route.ts +45 -0
- package/app/api/updates/health/route.ts +22 -0
- package/app/api/updates/route.test.ts +53 -0
- package/app/api/updates/settings/route.ts +37 -10
- package/components/Chat/ChatPanel.tsx +28 -0
- package/components/Chat/needs-more-history.test.ts +28 -0
- package/components/Chat/needs-more-history.ts +14 -0
- package/components/Recipes/TimerFields.tsx +2 -1
- package/components/Schedules/ScheduleForm.tsx +1 -1
- package/components/Settings/SettingsDialog.tsx +1 -1
- package/components/SidebarFooter.tsx +2 -0
- package/components/Tips/TipButton.tsx +39 -0
- package/components/Tips/TipDialog.tsx +148 -0
- package/components/Tips/TipHost.tsx +14 -0
- package/components/Tips/TipMedia.tsx +71 -0
- package/components/Tips/index.ts +2 -0
- package/components/Updates/AutoUpdates.tsx +141 -0
- package/components/Updates/UpdateBanner.test.ts +39 -1
- package/components/Updates/UpdateBanner.tsx +31 -5
- package/components/Updates/UpdatesSection.tsx +24 -3
- package/content/tips.json +456 -0
- package/content/whats-new.json +32 -0
- package/data/tips/index.ts +49 -0
- package/data/updates/index.ts +5 -0
- package/hooks/useReloadWhenUpdated.ts +5 -1
- package/lib/busy.ts +23 -1
- package/lib/chat/drivers/copilot-approvals.ts +2 -0
- package/lib/chat/drivers/copilot-ask.ts +232 -0
- package/lib/chat/drivers/copilot-mapper.ts +9 -0
- package/lib/chat/drivers/copilot.ts +18 -4
- package/lib/db/migrations.ts +21 -0
- package/lib/recipes/timer.ts +3 -1
- package/lib/schedules/cron.ts +14 -5
- package/lib/schedules/store.ts +3 -2
- package/lib/security/demo.ts +2 -0
- package/lib/tasks/finish.ts +5 -5
- package/lib/tips/conditions.ts +38 -0
- package/lib/tips/index.ts +2 -0
- package/lib/tips/load.ts +26 -0
- package/lib/tips/media.ts +101 -0
- package/lib/tips/pick.ts +35 -0
- package/lib/tips/schema.ts +82 -0
- package/lib/tips/state.ts +26 -0
- package/lib/tips/view.ts +60 -0
- package/lib/updates/auto.ts +149 -0
- package/lib/updates/banner.ts +78 -3
- package/lib/updates/check.ts +64 -8
- package/lib/updates/history.ts +99 -0
- package/lib/updates/run.ts +334 -23
- package/lib/updates/settings.ts +66 -0
- package/lib/updates/view.ts +14 -3
- package/package.json +2 -2
- package/public/tips/.gitkeep +0 -0
- package/public/tips/chat-slash-menu-poster.webp +0 -0
- package/public/tips/chat-slash-menu.mp4 +0 -0
- package/scripts/autodeploy +4 -0
- package/scripts/autodeploy.test.ts +12 -0
- package/scripts/docs-links.test.ts +1 -25
- package/scripts/lib/commands.sh +32 -1
- package/scripts/lib/deps-swap.sh +8 -6
- package/scripts/lib/heading-slugs.ts +26 -0
- package/scripts/lib/update-verify.mjs +90 -0
- package/scripts/redeploy +31 -2
- package/scripts/redeploy.test.ts +94 -3
- package/scripts/tips-lint.test.ts +77 -0
- package/scripts/tips-media.ts +31 -0
- package/scripts/update-e2e.test.ts +215 -0
- package/scripts/update-recover.mjs +130 -0
- package/scripts/update-recover.test.ts +144 -0
- package/scripts/update-run.mjs +182 -40
- package/scripts/update-run.test.ts +208 -2
- package/server.ts +3 -2
- package/stores/tips.ts +14 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import type { AddressInfo } from "node:net";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
6
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import type { ChatQuestion } from "../events";
|
|
9
|
+
import {
|
|
10
|
+
answersInOrder,
|
|
11
|
+
type ApprovalOutcome,
|
|
12
|
+
type PendingApprovals,
|
|
13
|
+
} from "./pending-approvals";
|
|
14
|
+
|
|
15
|
+
type P = Record<string, unknown>;
|
|
16
|
+
const str = (v: unknown) => (typeof v === "string" ? v : "");
|
|
17
|
+
|
|
18
|
+
export const ASK_SERVER = "agentos";
|
|
19
|
+
export const ASK_TOOL = "ask_user";
|
|
20
|
+
const MAX_BODY = 64 * 1024;
|
|
21
|
+
|
|
22
|
+
// Copilot CLI doesn't offer its own ask_user over ACP, so each conversation
|
|
23
|
+
// serves one: a question the agent asks becomes the chat's question card.
|
|
24
|
+
export const askInput = {
|
|
25
|
+
question: z.string().min(1).describe("The question to show the user"),
|
|
26
|
+
options: z
|
|
27
|
+
.array(
|
|
28
|
+
z.object({
|
|
29
|
+
label: z.string().min(1),
|
|
30
|
+
description: z.string().optional(),
|
|
31
|
+
})
|
|
32
|
+
)
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("Choices to offer; leave out for a free-text question"),
|
|
35
|
+
multiSelect: z
|
|
36
|
+
.boolean()
|
|
37
|
+
.optional()
|
|
38
|
+
.describe("Let the user pick more than one option"),
|
|
39
|
+
allowFreeText: z
|
|
40
|
+
.boolean()
|
|
41
|
+
.optional()
|
|
42
|
+
.describe("The user can always type their own answer too"),
|
|
43
|
+
header: z.string().optional().describe("A short label for the question"),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type AskArgs = {
|
|
47
|
+
question: string;
|
|
48
|
+
options?: { label: string; description?: string }[];
|
|
49
|
+
multiSelect?: boolean;
|
|
50
|
+
header?: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function askUserQuestion(a: AskArgs): ChatQuestion {
|
|
54
|
+
const seen = new Set<string>();
|
|
55
|
+
const options = (a.options ?? []).flatMap((o) => {
|
|
56
|
+
const label = o.label.trim();
|
|
57
|
+
if (!label || seen.has(label)) return [];
|
|
58
|
+
seen.add(label);
|
|
59
|
+
return [{ label, description: o.description?.trim() ?? "" }];
|
|
60
|
+
});
|
|
61
|
+
return {
|
|
62
|
+
question: a.question.trim(),
|
|
63
|
+
header: a.header?.trim() ?? "",
|
|
64
|
+
multiSelect: !!a.multiSelect && options.length > 0,
|
|
65
|
+
options,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const text = (t: string, isError = false): CallToolResult => ({
|
|
70
|
+
content: [{ type: "text", text: t }],
|
|
71
|
+
...(isError ? { isError } : {}),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export function askUserResult(
|
|
75
|
+
q: ChatQuestion,
|
|
76
|
+
o: ApprovalOutcome
|
|
77
|
+
): CallToolResult {
|
|
78
|
+
if (o.decision === "answer") {
|
|
79
|
+
const [list] = answersInOrder([q], o.answers);
|
|
80
|
+
return text(
|
|
81
|
+
JSON.stringify(
|
|
82
|
+
q.multiSelect ? { answers: list } : { answer: list[0] ?? "" }
|
|
83
|
+
)
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (o.decision === "expired")
|
|
87
|
+
return text("The question was cancelled before the user answered.", true);
|
|
88
|
+
return text("The user skipped this question without answering.");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Whether a Copilot tool call (a tool_call update or a permission request) is
|
|
92
|
+
// our ask_user. Its title carries the server and tool name; the input is the
|
|
93
|
+
// fallback for a title that doesn't.
|
|
94
|
+
export function isAskUserCall(call: P): boolean {
|
|
95
|
+
const raw = (call.rawInput ?? {}) as P;
|
|
96
|
+
const title = str(call.title);
|
|
97
|
+
return (
|
|
98
|
+
title.includes(ASK_TOOL) ||
|
|
99
|
+
(title.includes(ASK_SERVER) && typeof raw.question === "string")
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// One private MCP server per conversation: loopback only, behind a secret
|
|
104
|
+
// path, so nothing else can reach it or answer its cards.
|
|
105
|
+
export class CopilotAskServer {
|
|
106
|
+
private http?: http.Server;
|
|
107
|
+
private secret = crypto.randomBytes(32).toString("hex");
|
|
108
|
+
private next = 0;
|
|
109
|
+
private live = new Set<{ close(): Promise<void> }>();
|
|
110
|
+
|
|
111
|
+
constructor(private pending: PendingApprovals) {}
|
|
112
|
+
|
|
113
|
+
private build(): McpServer {
|
|
114
|
+
const server = new McpServer({ name: ASK_SERVER, version: "1.0.0" });
|
|
115
|
+
server.registerTool(
|
|
116
|
+
ASK_TOOL,
|
|
117
|
+
{
|
|
118
|
+
description:
|
|
119
|
+
"Ask the user a question and wait for the answer. Use it when you need a decision or missing information instead of asking in plain text. Offer options for a choice (multiSelect to allow several); with no options the user types an answer. Blocks until they answer.",
|
|
120
|
+
inputSchema: askInput,
|
|
121
|
+
},
|
|
122
|
+
async (args) => {
|
|
123
|
+
const q = askUserQuestion(args);
|
|
124
|
+
const o = await this.pending.ask({
|
|
125
|
+
id: `approval-ask-${++this.next}`,
|
|
126
|
+
toolName: "AskUserQuestion",
|
|
127
|
+
title: "Questions",
|
|
128
|
+
input: { questions: [q] },
|
|
129
|
+
questions: [q],
|
|
130
|
+
canAlways: false,
|
|
131
|
+
});
|
|
132
|
+
return askUserResult(q, o);
|
|
133
|
+
}
|
|
134
|
+
);
|
|
135
|
+
return server;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private allowed(req: http.IncomingMessage): number {
|
|
139
|
+
if (req.headers.origin) return 403;
|
|
140
|
+
const host = (req.headers.host ?? "").replace(/:\d+$/, "");
|
|
141
|
+
if (host !== "127.0.0.1" && host !== "localhost") return 403;
|
|
142
|
+
const want = Buffer.from(`/mcp/${this.secret}`);
|
|
143
|
+
const got = Buffer.from((req.url ?? "").split("?")[0]);
|
|
144
|
+
return got.length === want.length && crypto.timingSafeEqual(got, want)
|
|
145
|
+
? 200
|
|
146
|
+
: 404;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private async handle(
|
|
150
|
+
req: http.IncomingMessage,
|
|
151
|
+
res: http.ServerResponse
|
|
152
|
+
): Promise<void> {
|
|
153
|
+
const status = this.allowed(req);
|
|
154
|
+
if (status !== 200) {
|
|
155
|
+
res.writeHead(status).end();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (req.method !== "POST") {
|
|
159
|
+
res.writeHead(405, { allow: "POST" }).end();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
let size = 0;
|
|
163
|
+
const chunks: Buffer[] = [];
|
|
164
|
+
for await (const c of req) {
|
|
165
|
+
size += (c as Buffer).length;
|
|
166
|
+
if (size > MAX_BODY) {
|
|
167
|
+
res.writeHead(413).end();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
chunks.push(c as Buffer);
|
|
171
|
+
}
|
|
172
|
+
let body: unknown;
|
|
173
|
+
try {
|
|
174
|
+
body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
175
|
+
} catch {
|
|
176
|
+
res.writeHead(400).end();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const server = this.build();
|
|
180
|
+
const transport = new StreamableHTTPServerTransport({
|
|
181
|
+
sessionIdGenerator: undefined,
|
|
182
|
+
enableJsonResponse: true,
|
|
183
|
+
});
|
|
184
|
+
const handle = {
|
|
185
|
+
close: async () => {
|
|
186
|
+
await transport.close();
|
|
187
|
+
await server.close();
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
this.live.add(handle);
|
|
191
|
+
res.on("close", () => {
|
|
192
|
+
this.live.delete(handle);
|
|
193
|
+
void handle.close().catch(() => {});
|
|
194
|
+
});
|
|
195
|
+
await server.connect(transport);
|
|
196
|
+
await transport.handleRequest(req, res, body);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The `mcpServers` entry to give ACP session/new and session/load.
|
|
200
|
+
async start(): Promise<P> {
|
|
201
|
+
const server = http.createServer((req, res) => {
|
|
202
|
+
this.handle(req, res).catch(() => {
|
|
203
|
+
if (!res.headersSent) res.writeHead(500);
|
|
204
|
+
res.end();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
await new Promise<void>((resolve, reject) => {
|
|
208
|
+
server.once("error", reject);
|
|
209
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
210
|
+
});
|
|
211
|
+
this.http = server;
|
|
212
|
+
const { port } = server.address() as AddressInfo;
|
|
213
|
+
return {
|
|
214
|
+
type: "http",
|
|
215
|
+
name: ASK_SERVER,
|
|
216
|
+
url: `http://127.0.0.1:${port}/mcp/${this.secret}`,
|
|
217
|
+
headers: [],
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Cancels any question still waiting (its call returns a cancellation)
|
|
222
|
+
// and stops listening.
|
|
223
|
+
close(): void {
|
|
224
|
+
this.pending.expireAll();
|
|
225
|
+
const server = this.http;
|
|
226
|
+
this.http = undefined;
|
|
227
|
+
server?.close();
|
|
228
|
+
server?.closeAllConnections();
|
|
229
|
+
for (const h of this.live) void h.close().catch(() => {});
|
|
230
|
+
this.live.clear();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ChatContext, DriverEvent, UsageTotals } from "../events";
|
|
2
2
|
import { toolTitle } from "../tools";
|
|
3
|
+
import { isAskUserCall } from "./copilot-ask";
|
|
3
4
|
import { TurnItems, type ToolStart } from "./turn-items";
|
|
4
5
|
|
|
5
6
|
type P = Record<string, unknown>;
|
|
@@ -135,14 +136,22 @@ export class CopilotMapper {
|
|
|
135
136
|
this.block++;
|
|
136
137
|
}
|
|
137
138
|
|
|
139
|
+
// The card is the question's only trace, so its tool call stays hidden.
|
|
140
|
+
private asks = new Set<string>();
|
|
141
|
+
|
|
138
142
|
private started(update: P): void {
|
|
139
143
|
this.items.startTurn();
|
|
144
|
+
if (isAskUserCall(update)) {
|
|
145
|
+
this.asks.add(str(update.toolCallId));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
140
148
|
this.close();
|
|
141
149
|
this.items.tool(str(update.toolCallId), toolStart(update));
|
|
142
150
|
}
|
|
143
151
|
|
|
144
152
|
private updated(update: P): void {
|
|
145
153
|
const id = str(update.toolCallId);
|
|
154
|
+
if (this.asks.has(id)) return;
|
|
146
155
|
const status = str(update.status);
|
|
147
156
|
if (status !== "completed" && status !== "failed") return;
|
|
148
157
|
const start = toolStart(update);
|
|
@@ -6,6 +6,7 @@ import type { ChatConversation, ChatDriver, ChatStartOptions } from "../driver";
|
|
|
6
6
|
import type { ChatImage, ChatModel, DriverEvent } from "../events";
|
|
7
7
|
import { InputQueue } from "../queue";
|
|
8
8
|
import { AGENT_DEFAULT_MODEL } from "../../providers/registry";
|
|
9
|
+
import { ASK_SERVER, ASK_TOOL, CopilotAskServer } from "./copilot-ask";
|
|
9
10
|
import { CopilotApprovals } from "./copilot-approvals";
|
|
10
11
|
import { CopilotMapper } from "./copilot-mapper";
|
|
11
12
|
import { CopilotRpc } from "./copilot-rpc";
|
|
@@ -33,7 +34,10 @@ export function copilotArgs(
|
|
|
33
34
|
fallback = copilotDefaultModel({ ...process.env, ...o.env })
|
|
34
35
|
): string[] {
|
|
35
36
|
const model = o.model && o.model !== AGENT_DEFAULT_MODEL ? o.model : fallback;
|
|
36
|
-
return
|
|
37
|
+
return [
|
|
38
|
+
...(model ? ["--model", model] : []),
|
|
39
|
+
`--allow-tool=${ASK_SERVER}(${ASK_TOOL})`,
|
|
40
|
+
];
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
// The models a new ACP session offers (`models.availableModels`), with
|
|
@@ -117,6 +121,7 @@ export const copilotDriver: ChatDriver = {
|
|
|
117
121
|
const emit = (e: DriverEvent) => out.push(e);
|
|
118
122
|
const mapper = new CopilotMapper(emit);
|
|
119
123
|
const approvals = new CopilotApprovals(emit, options.access);
|
|
124
|
+
const ask = new CopilotAskServer(approvals.pending);
|
|
120
125
|
let sessionId: string | null = null;
|
|
121
126
|
let busy = false;
|
|
122
127
|
let intro = options.resumeId ? "" : (options.systemAppend ?? "").trim();
|
|
@@ -136,7 +141,7 @@ export const copilotDriver: ChatDriver = {
|
|
|
136
141
|
void approvals.handle(params).then((result) => rpc.reply(id, result));
|
|
137
142
|
},
|
|
138
143
|
exit(error) {
|
|
139
|
-
|
|
144
|
+
ask.close();
|
|
140
145
|
mapper.endTurn({ error });
|
|
141
146
|
out.end();
|
|
142
147
|
},
|
|
@@ -145,7 +150,15 @@ export const copilotDriver: ChatDriver = {
|
|
|
145
150
|
emit({ type: "usage_start", totals: { ...mapper.totals } });
|
|
146
151
|
|
|
147
152
|
const ready = rpc.initialize().then(async () => {
|
|
148
|
-
|
|
153
|
+
// Without it the agent just can't ask: the chat still works.
|
|
154
|
+
const mcpServers = await ask.start().then(
|
|
155
|
+
(s) => [s],
|
|
156
|
+
(e: Error) => {
|
|
157
|
+
mapper.items.error(`Questions aren't available: ${e.message}`);
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
);
|
|
161
|
+
const params = { cwd: options.cwd, mcpServers };
|
|
149
162
|
if (options.resumeId) {
|
|
150
163
|
mapper.replaying = true;
|
|
151
164
|
try {
|
|
@@ -170,6 +183,7 @@ export const copilotDriver: ChatDriver = {
|
|
|
170
183
|
emit({ type: "resume_id", id: sessionId });
|
|
171
184
|
});
|
|
172
185
|
ready.catch((error: Error) => {
|
|
186
|
+
ask.close();
|
|
173
187
|
mapper.items.error(`GitHub Copilot couldn't start: ${error.message}`);
|
|
174
188
|
mapper.endTurn({});
|
|
175
189
|
out.end();
|
|
@@ -243,7 +257,7 @@ export const copilotDriver: ChatDriver = {
|
|
|
243
257
|
};
|
|
244
258
|
},
|
|
245
259
|
close() {
|
|
246
|
-
|
|
260
|
+
ask.close();
|
|
247
261
|
rpc.close();
|
|
248
262
|
out.end();
|
|
249
263
|
},
|
package/lib/db/migrations.ts
CHANGED
|
@@ -26,6 +26,11 @@ export type { Migration } from "./migration-types";
|
|
|
26
26
|
// typecheck, lint and test) and is not committed. A duplicate id or key, or a
|
|
27
27
|
// file whose name does not match its id, throws when this module loads.
|
|
28
28
|
// lib/db/migrations.test.ts enforces the rest.
|
|
29
|
+
//
|
|
30
|
+
// A migration must leave the previous release runnable: an automatic update
|
|
31
|
+
// that isn't healthy rolls the code back but not the database (chat workers
|
|
32
|
+
// keep writing through the restart, so a restore would drop their work). Add
|
|
33
|
+
// columns and tables; don't rename or drop what the last release reads.
|
|
29
34
|
|
|
30
35
|
export const migrationKey = (m: Migration): string => m.key ?? String(m.id);
|
|
31
36
|
const migrationName = (m: Migration): string =>
|
|
@@ -93,6 +98,22 @@ function compareToUpTo(m: Migration, upTo: number | string): number {
|
|
|
93
98
|
// All migrations in order - never modify existing ones, only add new
|
|
94
99
|
export const migrations: Migration[] = buildRegistry(migrationFiles);
|
|
95
100
|
|
|
101
|
+
/** How many of this build's migrations the database has; the update health check reads it. */
|
|
102
|
+
export function migrationStatus(db: Database.Database): {
|
|
103
|
+
known: number;
|
|
104
|
+
applied: number;
|
|
105
|
+
} {
|
|
106
|
+
const rows = db.prepare(`SELECT id, key FROM _migrations`).all() as {
|
|
107
|
+
id: number;
|
|
108
|
+
key: string | null;
|
|
109
|
+
}[];
|
|
110
|
+
const have = new Set(rows.map((r) => r.key ?? String(r.id)));
|
|
111
|
+
return {
|
|
112
|
+
known: migrations.length,
|
|
113
|
+
applied: migrations.filter((m) => have.has(migrationKey(m))).length,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
96
117
|
// `upTo`: stop after this legacy id or timestamp key, inclusive (tests that
|
|
97
118
|
// start from an older database).
|
|
98
119
|
export function runMigrations(
|
package/lib/recipes/timer.ts
CHANGED
|
@@ -67,7 +67,9 @@ export function setTimer(
|
|
|
67
67
|
if (!isTimezone(timezone)) throw new Error(`Unknown time zone "${timezone}"`);
|
|
68
68
|
const bad = cronError(cron);
|
|
69
69
|
if (bad) throw new Error(bad);
|
|
70
|
-
if (
|
|
70
|
+
if (
|
|
71
|
+
minGapMinutes(cron, timezone, Date.now(), MIN_GAP_MINUTES) < MIN_GAP_MINUTES
|
|
72
|
+
)
|
|
71
73
|
throw new Error("A recipe runs at most once an hour");
|
|
72
74
|
const enabled = input.enabled ?? true;
|
|
73
75
|
if (!existing) {
|
package/lib/schedules/cron.ts
CHANGED
|
@@ -261,16 +261,25 @@ export const MIN_GAP_MINUTES = 60;
|
|
|
261
261
|
// A message to a session, at most this often.
|
|
262
262
|
export const MESSAGE_MIN_GAP_MINUTES = 10;
|
|
263
263
|
|
|
264
|
-
// The shortest gap between runs over the next few weeks of them.
|
|
264
|
+
// The shortest gap between runs over the next few weeks of them. With
|
|
265
|
+
// `stopBelow`, it returns as soon as a gap under it turns up: "every minute"
|
|
266
|
+
// then costs two searches, not two hundred.
|
|
265
267
|
export function minGapMinutes(
|
|
266
268
|
cron: string,
|
|
267
269
|
timezone: string,
|
|
268
|
-
from = Date.now()
|
|
270
|
+
from = Date.now(),
|
|
271
|
+
stopBelow = 0
|
|
269
272
|
): number {
|
|
270
|
-
const
|
|
273
|
+
const parsed = parseCron(cron);
|
|
271
274
|
let min = Infinity;
|
|
272
|
-
|
|
273
|
-
|
|
275
|
+
let prev = nextRun(parsed, from, timezone);
|
|
276
|
+
for (let i = 1; i < 200 && prev !== null; i++) {
|
|
277
|
+
const next = nextRun(parsed, prev, timezone);
|
|
278
|
+
if (next === null) break;
|
|
279
|
+
min = Math.min(min, (next - prev) / 60_000);
|
|
280
|
+
if (min < stopBelow) break;
|
|
281
|
+
prev = next;
|
|
282
|
+
}
|
|
274
283
|
return min;
|
|
275
284
|
}
|
|
276
285
|
|
package/lib/schedules/store.ts
CHANGED
|
@@ -298,7 +298,7 @@ function validate(
|
|
|
298
298
|
if (
|
|
299
299
|
!oneOff &&
|
|
300
300
|
(input.kind === "task" || input.kind === "session") &&
|
|
301
|
-
minGapMinutes(cron, timezone) < MIN_GAP_MINUTES
|
|
301
|
+
minGapMinutes(cron, timezone, Date.now(), MIN_GAP_MINUTES) < MIN_GAP_MINUTES
|
|
302
302
|
)
|
|
303
303
|
throw new Error(
|
|
304
304
|
"A task or session schedule runs at most once an hour; for more often, post to the orchestrator"
|
|
@@ -309,7 +309,8 @@ function validate(
|
|
|
309
309
|
if (
|
|
310
310
|
!oneOff &&
|
|
311
311
|
(message || agentCheckIn) &&
|
|
312
|
-
minGapMinutes(cron, timezone) <
|
|
312
|
+
minGapMinutes(cron, timezone, Date.now(), MESSAGE_MIN_GAP_MINUTES) <
|
|
313
|
+
MESSAGE_MIN_GAP_MINUTES
|
|
313
314
|
)
|
|
314
315
|
throw new Error(
|
|
315
316
|
`A message schedule runs at most every ${MESSAGE_MIN_GAP_MINUTES} minutes`
|
package/lib/security/demo.ts
CHANGED
|
@@ -150,6 +150,8 @@ const READS = [
|
|
|
150
150
|
"demo",
|
|
151
151
|
// The changelog: a static file, no agent.
|
|
152
152
|
"whats-new",
|
|
153
|
+
// Tip of the Day: reading is a static file plus two settings.
|
|
154
|
+
"tips",
|
|
153
155
|
].map(route);
|
|
154
156
|
|
|
155
157
|
// The only writes, each a database write and nothing more in demo mode:
|
package/lib/tasks/finish.ts
CHANGED
|
@@ -88,17 +88,17 @@ async function cleanup(
|
|
|
88
88
|
forgetPR(session.id);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
// Sign-offs between `gh pr merge` and the database knowing it, so the
|
|
92
|
-
// watcher doesn't read our own merge as one done on GitHub.
|
|
93
|
-
export const signingOff = new Set<string>();
|
|
94
|
-
|
|
95
91
|
// What happens after a merge: restack anything stacked on it, then remove the
|
|
96
92
|
// session, worktree and branch. Kept per task so Land and done can wait for
|
|
97
93
|
// it; on globalThis because the watcher's merges run in server.ts and done in
|
|
98
|
-
// a route bundle, which load this module separately.
|
|
94
|
+
// a route bundle, which load this module separately. lib/busy.ts counts both.
|
|
99
95
|
const g = globalThis as unknown as {
|
|
100
96
|
__agentosAfterMerge?: Map<string, Promise<void>>;
|
|
97
|
+
__agentosSigningOff?: Set<string>;
|
|
101
98
|
};
|
|
99
|
+
// Sign-offs between `gh pr merge` and the database knowing it, so the
|
|
100
|
+
// watcher doesn't read our own merge as one done on GitHub.
|
|
101
|
+
export const signingOff: Set<string> = (g.__agentosSigningOff ??= new Set());
|
|
102
102
|
const afterMerge: Map<string, Promise<void>> = (g.__agentosAfterMerge ??=
|
|
103
103
|
new Map());
|
|
104
104
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readConnect } from "@/lib/connect/config";
|
|
2
|
+
import { listBookmarks } from "@/lib/bookmarks/store";
|
|
3
|
+
import { listRecipes } from "@/lib/recipes/store";
|
|
4
|
+
import { listSchedules } from "@/lib/schedules/store";
|
|
5
|
+
import { listDevices } from "@/lib/security/devices";
|
|
6
|
+
import { listGroups } from "@/lib/session-groups/store";
|
|
7
|
+
import { listSharedLinks } from "@/lib/share/store";
|
|
8
|
+
import type { Condition, Tip } from "./schema";
|
|
9
|
+
|
|
10
|
+
const CHECKS: Record<Condition, () => boolean> = {
|
|
11
|
+
"no-paired-device": () => listDevices().length === 0,
|
|
12
|
+
"no-push-device": () => !listDevices().some((d) => d.push.enabled),
|
|
13
|
+
"connect-not-linked": () => !readConnect().ok,
|
|
14
|
+
"connect-linked": () => {
|
|
15
|
+
const c = readConnect();
|
|
16
|
+
return c.ok && c.enabled;
|
|
17
|
+
},
|
|
18
|
+
"no-schedules": () => listSchedules().length === 0,
|
|
19
|
+
"no-recipes": () => listRecipes().length === 0,
|
|
20
|
+
"no-shares": () => listSharedLinks().length === 0,
|
|
21
|
+
"no-groups": () => listGroups().length === 0,
|
|
22
|
+
"no-bookmarks": () =>
|
|
23
|
+
listBookmarks({ workspace: "all", limit: 1 }).bookmarks.length === 0,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Whether the tip applies now; a check that throws counts as no. */
|
|
27
|
+
export function tipApplies(
|
|
28
|
+
tip: Pick<Tip, "when">,
|
|
29
|
+
checks: Record<string, () => boolean> = CHECKS
|
|
30
|
+
): boolean {
|
|
31
|
+
return (tip.when ?? []).every((c) => {
|
|
32
|
+
try {
|
|
33
|
+
return checks[c]?.() ?? false;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
package/lib/tips/load.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileSchema, type Tip } from "./schema";
|
|
4
|
+
|
|
5
|
+
export const TIPS_FILE = "content/tips.json";
|
|
6
|
+
|
|
7
|
+
let cached: { file: string; mtime: number; tips: Tip[] } | null = null;
|
|
8
|
+
|
|
9
|
+
/** Every tip that isn't retired. A missing or unreadable file is empty. */
|
|
10
|
+
export function loadTips(root: string = process.cwd()): Tip[] {
|
|
11
|
+
const file = path.join(root, TIPS_FILE);
|
|
12
|
+
try {
|
|
13
|
+
const mtime = fs.statSync(file).mtimeMs;
|
|
14
|
+
if (cached?.file === file && cached.mtime === mtime) return cached.tips;
|
|
15
|
+
const parsed = fileSchema.safeParse(
|
|
16
|
+
JSON.parse(fs.readFileSync(file, "utf-8"))
|
|
17
|
+
);
|
|
18
|
+
const tips = parsed.success
|
|
19
|
+
? parsed.data.tips.filter((t) => !t.hidden)
|
|
20
|
+
: [];
|
|
21
|
+
cached = { file, mtime, tips };
|
|
22
|
+
return tips;
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileSchema, type Media, type MediaBrief, type Tip } from "./schema";
|
|
4
|
+
|
|
5
|
+
export const MEDIA_DIR = "public/tips";
|
|
6
|
+
export const BUDGET = {
|
|
7
|
+
still: 90 * 1024,
|
|
8
|
+
clip: 500 * 1024,
|
|
9
|
+
poster: 40 * 1024,
|
|
10
|
+
total: 6 * 1024 * 1024,
|
|
11
|
+
clipSeconds: 8,
|
|
12
|
+
width: 768,
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
/** The files a media value points at, as names inside public/tips. */
|
|
16
|
+
export function mediaFiles(m: Media): { name: string; max: number }[] {
|
|
17
|
+
const name = (src: string) => path.basename(src);
|
|
18
|
+
return m.kind === "image"
|
|
19
|
+
? [
|
|
20
|
+
{ name: name(m.src), max: BUDGET.still },
|
|
21
|
+
...(m.srcDark ? [{ name: name(m.srcDark), max: BUDGET.still }] : []),
|
|
22
|
+
]
|
|
23
|
+
: [
|
|
24
|
+
{ name: name(m.src), max: BUDGET.clip },
|
|
25
|
+
{ name: name(m.poster), max: BUDGET.poster },
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ManifestRow {
|
|
30
|
+
id: string;
|
|
31
|
+
title: string;
|
|
32
|
+
kind: MediaBrief["kind"];
|
|
33
|
+
shot: string;
|
|
34
|
+
theme: NonNullable<MediaBrief["theme"]>;
|
|
35
|
+
guide?: string;
|
|
36
|
+
target: { width: number; maxBytes: number; maxSeconds?: number };
|
|
37
|
+
dest: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What Luna still has to make: tips with a brief and no media yet. */
|
|
41
|
+
export function manifest(tips: Tip[]): ManifestRow[] {
|
|
42
|
+
return tips
|
|
43
|
+
.filter((t) => t.mediaBrief && !t.media)
|
|
44
|
+
.map((t) => {
|
|
45
|
+
const b = t.mediaBrief!;
|
|
46
|
+
const clip = b.kind === "clip";
|
|
47
|
+
return {
|
|
48
|
+
id: t.id,
|
|
49
|
+
title: t.title,
|
|
50
|
+
kind: b.kind,
|
|
51
|
+
shot: b.shot,
|
|
52
|
+
theme: b.theme ?? (clip ? "dark" : "both"),
|
|
53
|
+
...(t.guide && { guide: t.guide.file }),
|
|
54
|
+
target: {
|
|
55
|
+
width: BUDGET.width,
|
|
56
|
+
maxBytes: clip ? BUDGET.clip : BUDGET.still,
|
|
57
|
+
...(clip && { maxSeconds: BUDGET.clipSeconds }),
|
|
58
|
+
},
|
|
59
|
+
dest: `${MEDIA_DIR}/${t.id}.${clip ? "mp4" : "webp"}`,
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Copies the finished files from `from` into `root`/public/tips and writes each
|
|
66
|
+
* tip's `media` (dropping its brief). Throws before changing anything if an id
|
|
67
|
+
* is unknown, a file is missing, or a file is over its budget.
|
|
68
|
+
*/
|
|
69
|
+
export function applyHandback(
|
|
70
|
+
root: string,
|
|
71
|
+
from: string,
|
|
72
|
+
handback: Record<string, Media>
|
|
73
|
+
): string[] {
|
|
74
|
+
const file = path.join(root, "content/tips.json");
|
|
75
|
+
const parsed = fileSchema.parse(JSON.parse(fs.readFileSync(file, "utf-8")));
|
|
76
|
+
const copies: [string, string][] = [];
|
|
77
|
+
for (const [id, media] of Object.entries(handback)) {
|
|
78
|
+
if (!parsed.tips.some((t) => t.id === id))
|
|
79
|
+
throw new Error(`Unknown tip "${id}"`);
|
|
80
|
+
for (const { name, max } of mediaFiles(media)) {
|
|
81
|
+
const src = path.join(from, name);
|
|
82
|
+
if (!fs.existsSync(src)) throw new Error(`${id}: ${name} is missing`);
|
|
83
|
+
const size = fs.statSync(src).size;
|
|
84
|
+
if (size > max)
|
|
85
|
+
throw new Error(`${id}: ${name} is ${size} bytes, over ${max}`);
|
|
86
|
+
copies.push([src, path.join(root, MEDIA_DIR, name)]);
|
|
87
|
+
}
|
|
88
|
+
if (media.kind === "video" && media.durationMs > BUDGET.clipSeconds * 1000)
|
|
89
|
+
throw new Error(`${id}: clip is longer than ${BUDGET.clipSeconds}s`);
|
|
90
|
+
}
|
|
91
|
+
fs.mkdirSync(path.join(root, MEDIA_DIR), { recursive: true });
|
|
92
|
+
for (const [a, b] of copies) fs.copyFileSync(a, b);
|
|
93
|
+
const tips = parsed.tips.map((t) => {
|
|
94
|
+
const media = handback[t.id];
|
|
95
|
+
if (!media) return t;
|
|
96
|
+
const { mediaBrief: _brief, ...rest } = t;
|
|
97
|
+
return { ...rest, media };
|
|
98
|
+
});
|
|
99
|
+
fs.writeFileSync(file, JSON.stringify({ ...parsed, tips }, null, 2) + "\n");
|
|
100
|
+
return Object.keys(handback);
|
|
101
|
+
}
|
package/lib/tips/pick.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Tip } from "./schema";
|
|
2
|
+
|
|
3
|
+
export interface Pinned {
|
|
4
|
+
day: string;
|
|
5
|
+
id: string;
|
|
6
|
+
read: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Newest first; tips added the same day keep the order they have in the file. */
|
|
10
|
+
export const orderDeck = <T extends Pick<Tip, "id" | "added">>(
|
|
11
|
+
tips: T[]
|
|
12
|
+
): T[] =>
|
|
13
|
+
[...tips].sort((a, b) =>
|
|
14
|
+
a.added === b.added ? 0 : a.added < b.added ? 1 : -1
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The tip for `day`: the one already chosen today while it still applies,
|
|
19
|
+
* else the first the user hasn't seen, else (all seen) the one seen longest ago.
|
|
20
|
+
*/
|
|
21
|
+
export function pickToday(o: {
|
|
22
|
+
deck: { id: string }[];
|
|
23
|
+
seen: Record<string, string>;
|
|
24
|
+
pinned: Pinned | null;
|
|
25
|
+
day: string;
|
|
26
|
+
}): Pinned | null {
|
|
27
|
+
const { deck, seen, pinned, day } = o;
|
|
28
|
+
if (!deck.length) return null;
|
|
29
|
+
if (pinned?.day === day && deck.some((t) => t.id === pinned.id))
|
|
30
|
+
return pinned;
|
|
31
|
+
const fresh = deck.find((t) => !seen[t.id]);
|
|
32
|
+
if (fresh) return { day, id: fresh.id, read: false };
|
|
33
|
+
const oldest = deck.reduce((a, b) => (seen[b.id] < seen[a.id] ? b : a));
|
|
34
|
+
return { day, id: oldest.id, read: false };
|
|
35
|
+
}
|