@saadnvd1/agent-os 0.3.671 → 0.3.673

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 (75) hide show
  1. package/app/api/files/upload-temp/route.test.ts +144 -0
  2. package/app/api/files/upload-temp/route.ts +52 -21
  3. package/app/api/projects/[id]/route.ts +26 -0
  4. package/app/api/sessions/[id]/route.ts +2 -0
  5. package/app/api/sessions/route.ts +6 -2
  6. package/components/Chat/ChatPanel.tsx +9 -2
  7. package/components/Chat/FileChip.tsx +67 -0
  8. package/components/Chat/Messages.tsx +8 -0
  9. package/components/Chat/Queue.tsx +6 -0
  10. package/components/Chat/composer/Attachments.tsx +16 -2
  11. package/components/Chat/composer/ComposerBody.tsx +35 -14
  12. package/components/Chat/composer/Toolbar.tsx +11 -8
  13. package/components/Chat/composer/editor.test.ts +1 -1
  14. package/components/Chat/composer/paste.ts +5 -4
  15. package/components/Chat/composer/useAttachFiles.ts +142 -0
  16. package/components/Chat/composer/useComposerEditor.ts +3 -3
  17. package/components/Chat/useDraft.ts +9 -2
  18. package/components/Draft/DraftPanel.tsx +7 -4
  19. package/components/Projects/ProjectSettingsDialog.tsx +7 -0
  20. package/components/Projects/ProjectTierField.tsx +39 -0
  21. package/components/Terminal/index.tsx +6 -5
  22. package/components/Workspaces/ProjectTiersForm.tsx +33 -0
  23. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  24. package/data/chat/useChat.ts +10 -3
  25. package/data/client-config/queries.ts +18 -0
  26. package/data/projects/queries.ts +4 -0
  27. package/data/sessions/queries.ts +2 -1
  28. package/lib/attachments/check.ts +50 -0
  29. package/lib/attachments/message.ts +49 -0
  30. package/lib/attachments/policy.ts +116 -0
  31. package/lib/attachments/store.ts +92 -0
  32. package/lib/attachments/types.ts +8 -0
  33. package/lib/chat/client-send.ts +6 -2
  34. package/lib/chat/driver.ts +4 -1
  35. package/lib/chat/drivers/claude.ts +2 -0
  36. package/lib/chat/events.ts +13 -1
  37. package/lib/chat/pending.ts +2 -1
  38. package/lib/chat/queued.ts +24 -7
  39. package/lib/chat/runner.ts +11 -2
  40. package/lib/chat/worker/host.ts +7 -3
  41. package/lib/chat/worker/protocol.ts +2 -0
  42. package/lib/config/client-config-types.ts +14 -3
  43. package/lib/config/client-config.ts +1 -1
  44. package/lib/db/migrations/20261018T2130_project_tier.ts +38 -0
  45. package/lib/db/migrations/20261019T1200_chat_queue_files.ts +14 -0
  46. package/lib/db/types.ts +4 -0
  47. package/lib/file-upload.ts +61 -27
  48. package/lib/intake/intake.ts +2 -17
  49. package/lib/intake/sniff.ts +1 -1
  50. package/lib/orchestrator/armed.test-helpers.ts +4 -0
  51. package/lib/orchestrator/armed.ts +14 -1
  52. package/lib/orchestrator/brief.ts +9 -2
  53. package/lib/orchestrator/fast-lane-state.ts +69 -0
  54. package/lib/orchestrator/fast-lane.ts +96 -0
  55. package/lib/orchestrator/gates.ts +8 -2
  56. package/lib/orchestrator/project-tier-tool.ts +33 -0
  57. package/lib/orchestrator/review.ts +3 -0
  58. package/lib/orchestrator/serve.ts +6 -1
  59. package/lib/orchestrator/signoff.test-helpers.ts +3 -0
  60. package/lib/orchestrator/signoff.ts +36 -19
  61. package/lib/orchestrator/tool-names.ts +1 -0
  62. package/lib/orchestrator/tool-schemas.ts +8 -1
  63. package/lib/orchestrator/tools.ts +2 -0
  64. package/lib/orchestrator/watcher.ts +5 -0
  65. package/lib/project-tier/getProjectTier.ts +18 -0
  66. package/lib/project-tier/index.ts +3 -0
  67. package/lib/project-tier/setProjectTier.ts +14 -0
  68. package/lib/project-tier/types.ts +3 -0
  69. package/lib/sessions/launch.ts +12 -6
  70. package/lib/start-options.ts +8 -1
  71. package/lib/tasks/finish.ts +2 -1
  72. package/lib/tasks/index.ts +10 -5
  73. package/lib/tasks/state.ts +7 -1
  74. package/package.json +1 -1
  75. package/components/Chat/composer/useImageDrop.ts +0 -52
@@ -41,6 +41,8 @@ import {
41
41
  reviewSignature,
42
42
  } from "./loop-guard";
43
43
  import { addNote } from "./notes";
44
+ import { isFast } from "../project-tier/getProjectTier";
45
+ import { recordFastMerge } from "./fast-lane-state";
44
46
  import { latestApproval, refundApproval, spendApproval } from "./ask-approvals";
45
47
  import { workSubject } from "./asks";
46
48
  import { carryApproval } from "./approval-carry";
@@ -71,7 +73,7 @@ export function restackedOf(task: Session) {
71
73
 
72
74
  // `approval`: Saad approved this merge on his asks list, for this commit.
73
75
  export type Verdict =
74
- | { ok: true; sha: string; pr: number; approval?: number }
76
+ | { ok: true; sha: string; pr: number; approval?: number; fast?: true }
75
77
  | { ok: false; wait: boolean; text: string };
76
78
 
77
79
  // Whether the task may merge now, and at which commit. Landing merges a
@@ -107,13 +109,15 @@ export async function judge(
107
109
  // A PR quick_pr opened (by the row AgentOS wrote, never the body): its
108
110
  // code review is the independent review of this head, and until that
109
111
  // passes it waits, before any gate is judged or counted.
110
- const quick = isQuick(task)
111
- ? quickCodeReview(
112
- sha,
113
- getCheck(task.id, sha, "review"),
114
- restackedOf(task)?.to === sha
115
- )
116
- : null;
112
+ const fast = isFast(task);
113
+ const quick =
114
+ isQuick(task) && !fast
115
+ ? quickCodeReview(
116
+ sha,
117
+ getCheck(task.id, sha, "review"),
118
+ restackedOf(task)?.to === sha
119
+ )
120
+ : null;
117
121
  // Saad's approval of this head stands in for that review (gateVerdict).
118
122
  if (
119
123
  quick?.wait &&
@@ -157,7 +161,7 @@ export async function judge(
157
161
  return quick ? { refusal: gate.refusal, wait: null } : gate;
158
162
  })(),
159
163
  }),
160
- opts
164
+ { ...opts, fast }
161
165
  );
162
166
  }
163
167
 
@@ -178,8 +182,9 @@ export async function gateVerdict(
178
182
  workspaceId: string,
179
183
  work: GateWork,
180
184
  inputs: () => Promise<Omit<GateInput, "sha" | "ruleBreaks">>,
181
- opts: { count?: boolean } = {}
185
+ opts: { count?: boolean; fast?: boolean } = {}
182
186
  ): Promise<Verdict> {
187
+ const fast = !!opts.fast;
183
188
  const no = (text: string, wait = false): Verdict => ({
184
189
  ok: false,
185
190
  wait,
@@ -216,7 +221,9 @@ export async function gateVerdict(
216
221
  : null;
217
222
  const refusal = i.codeReview.refusal ?? stack;
218
223
  if (refusal) return no(`${approved} ${refusal}`);
219
- const ci = ciGate({ sha, ...i, repoCI: await repoCIFor(repo, work, i) });
224
+ const ci = fast
225
+ ? { state: "pass" as const }
226
+ : ciGate({ sha, ...i, repoCI: await repoCIFor(repo, work, i) });
220
227
  if (ci.state === "fail") return no(`${approved} ${ci.reason}`);
221
228
  if (ci.state === "wait") return no(`${approved} ${ci.reason}`, true);
222
229
  // "escalate" is a repository with no CI: Saad is who that goes to.
@@ -245,7 +252,7 @@ export async function gateVerdict(
245
252
  }
246
253
  // Here too, not only in the review: one reviewed in parts while approvals
247
254
  // were off goes to Saad once they're on.
248
- if (mergeApprovalsOn()) {
255
+ if (mergeApprovalsOn() && !fast) {
249
256
  const size = (await fullDiff(repo, base, sha)).length;
250
257
  if (size > DIFF_CAP) {
251
258
  const why = `PR #${pr.number}'s diff at ${short(sha)} is ${size} characters, too big to review whole`;
@@ -255,8 +262,11 @@ export async function gateVerdict(
255
262
 
256
263
  const ruled = ruleBreaks(files, await addedLines(repo, base, sha));
257
264
  const live = await inputs();
258
- const repoCI = await repoCIFor(repo, base, live);
259
- const outcomes = evaluateGates({ sha, ruleBreaks: ruled, ...live, repoCI });
265
+ const repoCI = fast ? undefined : await repoCIFor(repo, base, live);
266
+ const outcomes = evaluateGates(
267
+ { sha, ruleBreaks: ruled, ...live, repoCI },
268
+ { fast }
269
+ );
260
270
  const head = `${work.name} (PR #${pr.number} at ${short(sha)}) can't merge:`;
261
271
  const toSaad = outcomes.find((o) => o.state === "escalate");
262
272
  if (toSaad)
@@ -266,9 +276,12 @@ export async function gateVerdict(
266
276
  const failed = outcomes.filter((o) => o.state === "fail");
267
277
  const waiting = outcomes.filter((o) => o.state === "wait");
268
278
  if (!failed.length && !waiting.length)
269
- return { ok: true, sha, pr: pr.number };
279
+ return fast
280
+ ? { ok: true, sha, pr: pr.number, fast: true }
281
+ : { ok: true, sha, pr: pr.number };
270
282
  const lines: string[] = [];
271
- if (opts.count === false)
283
+ // The fast lane has no loop guard: a failure is told, never counted.
284
+ if (opts.count === false || fast)
272
285
  lines.push(...failed.map((o) => `- ${o.gate} failed: ${o.reason}`));
273
286
  else {
274
287
  for (const o of outcomes)
@@ -346,11 +359,15 @@ export async function mergeJudged(
346
359
  await refundIfRefused(task.id, verdict.approval, async () => {
347
360
  method = await signOffTask(task.id, { head: verdict.sha, wait: opts.wait });
348
361
  });
362
+ if (verdict.fast)
363
+ recordFastMerge(task.id, workspaceId, verdict.pr, verdict.sha);
349
364
  addNote(
350
365
  workspaceId,
351
- verdict.approval
352
- ? `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}) on Saad's approval of that commit.`
353
- : `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}): CI green, review passed, in scope.`
366
+ verdict.fast
367
+ ? `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}) on the fast lane: no CI wait or review.`
368
+ : verdict.approval
369
+ ? `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}) on Saad's approval of that commit.`
370
+ : `Merged ${task.name} (PR #${verdict.pr}, ${short(verdict.sha)}): CI green, review passed, in scope.`
354
371
  );
355
372
  return `Merged ${task.name}: PR #${verdict.pr} ${method ? MERGED_AS[method] : "merged"} at ${short(verdict.sha)}.`;
356
373
  }
@@ -40,6 +40,7 @@ export const TOOL_NAMES = {
40
40
  archive: name("archive"),
41
41
  resurrect: name("resurrect"),
42
42
  add_project: name("add_project"),
43
+ project_tier: name("project_tier"),
43
44
  note: name("note"),
44
45
  question: name("question"),
45
46
  resolve_question: name("resolve_question"),
@@ -5,6 +5,7 @@ import { z } from "zod";
5
5
  import { BRANCH_NAME } from "../git";
6
6
  import { RAISED_KINDS } from "./asks";
7
7
  import { MAX_RULE_CHARS } from "./rules";
8
+ import { PROJECT_TIERS } from "../project-tier/types";
8
9
  import { PROJECT_NAME } from "../project-add/name";
9
10
  import { TASK_ROLES, type ModelRole } from "../models/roles";
10
11
  import { PRIORITIES } from "../tasks/schedule-types";
@@ -160,8 +161,9 @@ export const TOOL_SHAPES = {
160
161
  model: modelOverride,
161
162
  plan: z
162
163
  .boolean()
164
+ .optional()
163
165
  .describe(
164
- "Your decision for this job: true to plan first on the planning model, then implement on the implementation model; false to skip the plan and put the steps in the prompt yourself"
166
+ "Your decision for this job: true to plan first on the planning model, then implement on the implementation model; false to skip the plan and put the steps in the prompt yourself. Default: false in a fast project, the workspace's setting in a production one"
165
167
  ),
166
168
  plan_only: z
167
169
  .boolean()
@@ -174,6 +176,7 @@ export const TOOL_SHAPES = {
174
176
  .trim()
175
177
  .min(3)
176
178
  .max(200)
179
+ .optional()
177
180
  .describe(
178
181
  'Why, in a few words Saad reads on the task card: "touches 6 files across gates and UI", "one-line default change"'
179
182
  ),
@@ -338,6 +341,10 @@ export const TOOL_SHAPES = {
338
341
  .optional()
339
342
  .describe("A new project's name (default: the folder's)"),
340
343
  },
344
+ project_tier: {
345
+ project: z.string().trim().min(1).max(200).optional(),
346
+ tier: z.enum(PROJECT_TIERS).optional(),
347
+ },
341
348
  note: { text: z.string().trim().min(1).max(2000) },
342
349
  question: {
343
350
  text: z
@@ -165,6 +165,8 @@ const DESCRIPTIONS: Record<ToolName, string> = {
165
165
  "Bring a dropped, done or archived session back: the same session and conversation, its worktree (with its uncommitted files and plan) back from what was saved or from its branch, its status as it was (a plan-only task waits for approval again, one with a PR is idle), and a PR a drop closed reopened. Archived sessions just unarchive. Held by Pause and braked like a start. Refused when its conversation is gone, its PR was merged, its project was removed or its branch is in use. Give a reason; it goes in the decision log.",
166
166
  add_project:
167
167
  "Make a git repository on this machine a project of this workspace, by its absolute path, or move the project already at that path into this workspace when it's in no workspace. Says its default branch, detected from git. Refuses a folder that isn't a git repository or that holds secrets, and a project another workspace has.",
168
+ project_tier:
169
+ "Read or set a project's tier. production: every merge gate applies (CI, independent review, scope). fast: a task's PR merges as soon as it opens, with no CI wait or review. Without a project, lists this workspace's; with a project, reads it; with a tier too, sets it, only when Saad says to.",
168
170
  note: "Add a line to this workspace's decision log; it shows in your chat.",
169
171
  rule_add:
170
172
  'Keep a standing rule for this workspace, in your brief on every start from now on ("never merge #111", "briefs must not name third-party products"). It restricts or informs; it can never approve anything.',
@@ -5,6 +5,7 @@
5
5
  * every 15s while anything in the workspace is working, every 60s when not.
6
6
  */
7
7
 
8
+ import { autoArmFast, followFastMerges } from "./fast-lane";
8
9
  import { chatState, chatStateNow, sendChat } from "../chat/runner";
9
10
  import type { ChatState } from "../chat/events";
10
11
  import { getWorkspace } from "../workspaces";
@@ -96,7 +97,11 @@ function mergePipeline(workspaceId: string, facts: SessionFacts[]): void {
96
97
  console.error(`Judging asked commits in ${workspaceId}:`, error)
97
98
  );
98
99
  await rebaseConflicting(workspaceId, facts);
100
+ await autoArmFast(workspaceId, facts);
99
101
  await mergeArmed(workspaceId, facts);
102
+ await followFastMerges(workspaceId).catch((error) =>
103
+ console.error(`Following fast merges in ${workspaceId}:`, error)
104
+ );
100
105
  })()
101
106
  .catch((error) =>
102
107
  console.error(`Merge pipeline for ${workspaceId}:`, error)
@@ -0,0 +1,18 @@
1
+ import { db } from "@/lib/db";
2
+ import type { ProjectTier } from "./types";
3
+
4
+ // An unknown project counts as production: a missing row never opens the
5
+ // fast lane.
6
+ export function projectTier(projectId: string | null): ProjectTier {
7
+ if (!projectId) return "production";
8
+ const row = db
9
+ .prepare(`SELECT tier FROM projects WHERE id = ?`)
10
+ .get(projectId) as { tier: string } | undefined;
11
+ return row?.tier === "fast" ? "fast" : "production";
12
+ }
13
+
14
+ export const taskTier = (task: { project_id: string | null }): ProjectTier =>
15
+ projectTier(task.project_id);
16
+
17
+ export const isFast = (task: { project_id: string | null }): boolean =>
18
+ taskTier(task) === "fast";
@@ -0,0 +1,3 @@
1
+ export { PROJECT_TIERS, type ProjectTier } from "./types";
2
+ export { projectTier, taskTier, isFast } from "./getProjectTier";
3
+ export { setProjectTier } from "./setProjectTier";
@@ -0,0 +1,14 @@
1
+ import { getProject } from "@/lib/projects";
2
+ import { db } from "@/lib/db";
3
+ import type { Project } from "@/lib/db/types";
4
+ import type { ProjectTier } from "./types";
5
+
6
+ export function setProjectTier(projectId: string, tier: ProjectTier): Project {
7
+ const project = getProject(projectId);
8
+ if (!project || project.is_uncategorized)
9
+ throw new Error("Project not found or cannot be modified");
10
+ db.prepare(
11
+ `UPDATE projects SET tier = ?, updated_at = datetime('now') WHERE id = ?`
12
+ ).run(tier, projectId);
13
+ return getProject(projectId)!;
14
+ }
@@ -0,0 +1,3 @@
1
+ export type { ProjectTier } from "@/lib/db/types";
2
+
3
+ export const PROJECT_TIERS = ["production", "fast"] as const;
@@ -10,7 +10,7 @@ import os from "os";
10
10
  import { randomUUID } from "crypto";
11
11
  import { db, queries, type Session } from "../db";
12
12
  import type { AgentType } from "../providers";
13
- import type { ChatAccess, ChatImage } from "../chat/events";
13
+ import type { ChatAccess, ChatFile, ChatImage } from "../chat/events";
14
14
  import { resolveModelForAgent } from "../model-catalog";
15
15
  import { parseOverride, resolveRole, setSessionModel } from "../models/resolve";
16
16
  import { ANTHROPIC } from "../models/roles";
@@ -69,6 +69,7 @@ export interface LaunchInput {
69
69
  // The first message.
70
70
  prompt?: string | null;
71
71
  images?: ChatImage[];
72
+ files?: ChatFile[];
72
73
  name?: string | null;
73
74
  view?: "chat" | "terminal";
74
75
  parentSessionId?: string | null;
@@ -103,9 +104,14 @@ function firstMessage(projectPrompt: string | null | undefined, text: string) {
103
104
  }
104
105
 
105
106
  // Waits in the queue, so neither a restart nor a slow setup loses it.
106
- function queueFirst(sessionId: string, text: string, images?: ChatImage[]) {
107
+ function queueFirst(
108
+ sessionId: string,
109
+ text: string,
110
+ images?: ChatImage[],
111
+ files?: ChatFile[]
112
+ ) {
107
113
  const id = `user-${Date.now()}-${randomUUID().slice(0, 5)}`;
108
- enqueue(sessionId, { id, text, images });
114
+ enqueue(sessionId, { id, text, images, files });
109
115
  return id;
110
116
  }
111
117
 
@@ -201,8 +207,8 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
201
207
  // A linked machine's AgentOS starts it there; it is mirrored here.
202
208
  const link = remote ? hostLink(runOn) : null;
203
209
  if (link) {
204
- if (input.images?.length)
205
- throw new Error(`Images can't be sent to ${link.hostName} yet`);
210
+ if (input.images?.length || input.files?.length)
211
+ throw new Error(`Images and files can't be sent to ${link.hostName} yet`);
206
212
  return startOnPeer(link, {
207
213
  id,
208
214
  project: scratch ? null : (project ?? null),
@@ -305,7 +311,7 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
305
311
  : "";
306
312
  const chatFirst =
307
313
  view === "chat" && message
308
- ? queueFirst(id, message, input.images)
314
+ ? queueFirst(id, message, input.images, input.files)
309
315
  : undefined;
310
316
 
311
317
  if (useWorktree) {
@@ -40,7 +40,14 @@ export async function startOptions(
40
40
  const opts = { workspaceId: project?.workspace_id ?? null };
41
41
  roles.plan = resolveRole("plan", opts, models).ref;
42
42
  roles.implement = resolveRole("implement", opts, models).ref;
43
- planDefault = taskRouting(opts.workspaceId, "chat", undefined).plan;
43
+ planDefault = taskRouting(
44
+ opts.workspaceId,
45
+ "chat",
46
+ undefined,
47
+ undefined,
48
+ undefined,
49
+ project?.tier
50
+ ).plan;
44
51
  } catch (error) {
45
52
  planError = error instanceof Error ? error.message : String(error);
46
53
  }
@@ -40,6 +40,7 @@ import { sessionEnded } from "../experiments/hooks";
40
40
  import { snapshotWorktree } from "../resurrect/snapshot";
41
41
  import { keepSessionFiles } from "../session-artifacts/collect";
42
42
  import { setPr, setTask } from "../db/session-parts";
43
+ import { isFast } from "../project-tier/getProjectTier";
43
44
 
44
45
  export const sessionMergePolicy = (session: Session): MergePolicy =>
45
46
  mergePolicy(session.project_id ? getProject(session.project_id) : null);
@@ -167,7 +168,7 @@ export async function signOffTask(
167
168
  throw new Error(`${opts.base} isn't a base ${session.name} may merge into`);
168
169
  const judgedBase = opts.base ?? session.base_branch;
169
170
  const pr = await prFor(session, true);
170
- const verdict = canSignOff(pr);
171
+ const verdict = canSignOff(pr, { ci: !isFast(session) });
171
172
  if (!verdict.ok) throw new Error(verdict.reason);
172
173
  if (pr!.base && pr!.base !== judgedBase)
173
174
  throw new Error(
@@ -15,7 +15,7 @@ import { effectiveModels } from "../models/config";
15
15
  import { ANTHROPIC } from "../models/roles";
16
16
  import { processTable, runsSomething } from "../process-table";
17
17
  import { randomUUID } from "crypto";
18
- import { db, queries, type Session } from "../db";
18
+ import { db, queries, type ProjectTier, type Session } from "../db";
19
19
  import { getProject } from "../projects";
20
20
  import {
21
21
  createWorktree,
@@ -267,7 +267,8 @@ async function startTask(
267
267
  view,
268
268
  opts.model,
269
269
  opts.plan,
270
- opts.planOnly
270
+ opts.planOnly,
271
+ project.tier
271
272
  ).plan;
272
273
  const session = await startRemoteTask(hostId, project, {
273
274
  id,
@@ -310,7 +311,8 @@ async function startTask(
310
311
  view,
311
312
  opts.model,
312
313
  opts.plan,
313
- opts.planOnly
314
+ opts.planOnly,
315
+ project.tier
314
316
  )
315
317
  : otherAgentRouting(opts.model);
316
318
  const projectPath = expandHome(project.working_directory);
@@ -590,7 +592,9 @@ export function taskRouting(
590
592
  view: StartView,
591
593
  model: unknown,
592
594
  plan?: boolean,
593
- planOnly?: boolean
595
+ planOnly?: boolean,
596
+ // A fast project skips the plan step unless the caller asks for it.
597
+ tier: ProjectTier = "production"
594
598
  ) {
595
599
  const override = parseOverride(model);
596
600
  const models = effectiveModels();
@@ -601,7 +605,8 @@ export function taskRouting(
601
605
  const planStep =
602
606
  view === "chat" &&
603
607
  (!!planOnly ||
604
- ((plan ?? models.planStep) && planning.ref !== implement.ref));
608
+ ((plan ?? (tier === "fast" ? false : models.planStep)) &&
609
+ planning.ref !== implement.ref));
605
610
  return {
606
611
  override,
607
612
  plan: planStep,
@@ -97,13 +97,19 @@ export function needsHuman(state: TaskState): boolean {
97
97
  ].includes(state);
98
98
  }
99
99
 
100
- export function canSignOff(pr: TaskPR | null): {
100
+ // `ci: false` (a fast project): checks that are failing or running don't
101
+ // refuse the merge.
102
+ export function canSignOff(
103
+ pr: TaskPR | null,
104
+ opts: { ci?: boolean } = {}
105
+ ): {
101
106
  ok: boolean;
102
107
  reason?: string;
103
108
  } {
104
109
  if (!pr) return { ok: false, reason: "No pull request yet" };
105
110
  if (pr.state !== "OPEN")
106
111
  return { ok: false, reason: `PR is ${pr.state.toLowerCase()}` };
112
+ if (opts.ci === false) return { ok: true };
107
113
  if (pr.checks === "fail")
108
114
  return { ok: false, reason: "CI checks are failing" };
109
115
  if (pr.checks === "pending")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.671",
3
+ "version": "0.3.673",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -1,52 +0,0 @@
1
- "use client";
2
-
3
- import { useState } from "react";
4
- import type { ChatImage } from "@/lib/chat/events";
5
- import { isImage } from "./paste";
6
-
7
- function readImage(file: File): Promise<ChatImage> {
8
- return new Promise((resolve, reject) => {
9
- const reader = new FileReader();
10
- reader.onload = () => {
11
- const [meta, data] = String(reader.result).split(",");
12
- resolve({ mediaType: meta.slice(5, meta.indexOf(";")), data });
13
- };
14
- reader.onerror = reject;
15
- reader.readAsDataURL(file);
16
- });
17
- }
18
-
19
- const carriesFiles = (e: React.DragEvent) =>
20
- [...e.dataTransfer.types].includes("Files");
21
-
22
- // Images picked, pasted or dropped onto the composer, and the outline
23
- // shown while something is dragged over it.
24
- export function useImageDrop(onImages: (images: ChatImage[]) => void) {
25
- const [dragging, setDragging] = useState(false);
26
-
27
- const addFiles = async (picked: Iterable<File>) => {
28
- const read = await Promise.all([...picked].filter(isImage).map(readImage));
29
- if (read.length) onImages(read);
30
- };
31
-
32
- const dropProps = {
33
- onDragOver: (e: React.DragEvent) => {
34
- if (!carriesFiles(e)) return;
35
- e.preventDefault();
36
- e.dataTransfer.dropEffect = "copy";
37
- setDragging(true);
38
- },
39
- onDragLeave: (e: React.DragEvent) => {
40
- if (!e.currentTarget.contains(e.relatedTarget as Node | null))
41
- setDragging(false);
42
- },
43
- onDrop: (e: React.DragEvent) => {
44
- if (!carriesFiles(e)) return;
45
- e.preventDefault();
46
- setDragging(false);
47
- void addFiles(e.dataTransfer.files);
48
- },
49
- };
50
-
51
- return { dragging, dropProps, addFiles };
52
- }