Merge remote-tracking branch 'public-gh/master'
* public-gh/master: Default Gemini adapter to yolo mode and add API access prompt note fix: remove Cmd+1..9 company-switch shortcut fix(ui): prevent IME composition Enter from moving focus in new issue title fix(cli): add restart hint after allowed-hostname change docs: remove obsolete TODO for CONTRIBUTING.md fix: default dangerouslySkipPermissions to true for unattended agents fix: route heartbeat cost recording through costService Show issue creator in properties sidebar
This commit is contained in:
@@ -248,8 +248,6 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
|
||||
|
||||
We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for details.
|
||||
|
||||
<!-- TODO: add CONTRIBUTING.md -->
|
||||
|
||||
<br/>
|
||||
|
||||
## Community
|
||||
|
||||
@@ -26,6 +26,9 @@ export async function addAllowedHostname(host: string, opts: { config?: string }
|
||||
p.log.info(`Hostname ${pc.cyan(normalized)} is already allowed.`);
|
||||
} else {
|
||||
p.log.success(`Added allowed hostname: ${pc.cyan(normalized)}`);
|
||||
p.log.message(
|
||||
pc.dim("Restart the Paperclip server for this change to take effect."),
|
||||
);
|
||||
}
|
||||
|
||||
if (!(config.server.deploymentMode === "authenticated" && config.server.exposure === "private")) {
|
||||
|
||||
@@ -75,6 +75,14 @@ export interface AdapterExecutionResult {
|
||||
runtimeServices?: AdapterRuntimeServiceReport[];
|
||||
summary?: string | null;
|
||||
clearSession?: boolean;
|
||||
question?: {
|
||||
prompt: string;
|
||||
choices: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface AdapterSessionCodec {
|
||||
|
||||
@@ -30,7 +30,6 @@ Core fields:
|
||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
|
||||
- promptTemplate (string, optional): run prompt template
|
||||
- model (string, optional): Gemini model id. Defaults to auto.
|
||||
- approvalMode (string, optional): "default", "auto_edit", or "yolo" (default: "default")
|
||||
- sandbox (boolean, optional): run in sandbox mode (default: false, passes --sandbox=none)
|
||||
- command (string, optional): defaults to "gemini"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
|
||||
@@ -59,6 +59,20 @@ function renderPaperclipEnvNote(env: Record<string, string>): string {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderApiAccessNote(env: Record<string, string>): string {
|
||||
if (!hasNonEmptyEnvValue(env, "PAPERCLIP_API_URL") || !hasNonEmptyEnvValue(env, "PAPERCLIP_API_KEY")) return "";
|
||||
return [
|
||||
"Paperclip API access note:",
|
||||
"Use run_shell_command with curl to make Paperclip API requests.",
|
||||
"GET example:",
|
||||
` run_shell_command({ command: "curl -s -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" \\"$PAPERCLIP_API_URL/api/agents/me\\"" })`,
|
||||
"POST/PATCH example:",
|
||||
` run_shell_command({ command: "curl -s -X POST -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" -H 'Content-Type: application/json' -H \\"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\\" -d '{...}' \\"$PAPERCLIP_API_URL/api/issues/{id}/checkout\\"" })`,
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function resolvePaperclipSkillsDir(): Promise<string | null> {
|
||||
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
|
||||
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
||||
@@ -132,7 +146,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
);
|
||||
const command = asString(config.command, "gemini");
|
||||
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
|
||||
const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default");
|
||||
const sandbox = asBoolean(config.sandbox, false);
|
||||
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
@@ -250,7 +263,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
}
|
||||
const commandNotes = (() => {
|
||||
const notes: string[] = ["Prompt is passed to Gemini as the final positional argument."];
|
||||
if (approvalMode !== "default") notes.push(`Added --approval-mode ${approvalMode} for unattended execution.`);
|
||||
notes.push("Added --approval-mode yolo for unattended execution.");
|
||||
if (!instructionsFilePath) return notes;
|
||||
if (instructionsPrefix.length > 0) {
|
||||
notes.push(
|
||||
@@ -275,13 +288,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
context,
|
||||
});
|
||||
const paperclipEnvNote = renderPaperclipEnvNote(env);
|
||||
const prompt = `${instructionsPrefix}${paperclipEnvNote}${renderedPrompt}`;
|
||||
const apiAccessNote = renderApiAccessNote(env);
|
||||
const prompt = `${instructionsPrefix}${paperclipEnvNote}${apiAccessNote}${renderedPrompt}`;
|
||||
|
||||
const buildArgs = (resumeSessionId: string | null) => {
|
||||
const args = ["--output-format", "stream-json"];
|
||||
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
||||
if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
|
||||
if (approvalMode !== "default") args.push("--approval-mode", approvalMode);
|
||||
args.push("--approval-mode", "yolo");
|
||||
if (sandbox) {
|
||||
args.push("--sandbox");
|
||||
} else {
|
||||
@@ -398,6 +412,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
stderr: attempt.proc.stderr,
|
||||
},
|
||||
summary: attempt.parsed.summary,
|
||||
question: attempt.parsed.question,
|
||||
clearSession: clearSessionForTurnLimit || Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -78,6 +78,7 @@ export function parseGeminiJsonl(stdout: string) {
|
||||
let errorMessage: string | null = null;
|
||||
let costUsd: number | null = null;
|
||||
let resultEvent: Record<string, unknown> | null = null;
|
||||
let question: { prompt: string; choices: Array<{ key: string; label: string; description?: string }> } | null = null;
|
||||
const usage = {
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
@@ -98,6 +99,25 @@ export function parseGeminiJsonl(stdout: string) {
|
||||
|
||||
if (type === "assistant") {
|
||||
messages.push(...collectMessageText(event.message));
|
||||
const messageObj = parseObject(event.message);
|
||||
const content = Array.isArray(messageObj.content) ? messageObj.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = parseObject(partRaw);
|
||||
if (asString(part.type, "").trim() === "question") {
|
||||
question = {
|
||||
prompt: asString(part.prompt, "").trim(),
|
||||
choices: (Array.isArray(part.choices) ? part.choices : []).map((choiceRaw) => {
|
||||
const choice = parseObject(choiceRaw);
|
||||
return {
|
||||
key: asString(choice.key, "").trim(),
|
||||
label: asString(choice.label, "").trim(),
|
||||
description: asString(choice.description, "").trim() || undefined,
|
||||
};
|
||||
}),
|
||||
};
|
||||
break; // only one question per message
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -154,6 +174,7 @@ export function parseGeminiJsonl(stdout: string) {
|
||||
costUsd,
|
||||
errorMessage,
|
||||
resultEvent,
|
||||
question,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -67,8 +67,8 @@ export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, un
|
||||
}
|
||||
}
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
if (v.dangerouslyBypassSandbox) ac.approvalMode = "yolo";
|
||||
ac.sandbox = !v.dangerouslyBypassSandbox;
|
||||
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
|
||||
@@ -39,6 +39,37 @@ describe("gemini_local parser", () => {
|
||||
expect(parsed.costUsd).toBeCloseTo(0.00123, 6);
|
||||
expect(parsed.errorMessage).toBe("model access denied");
|
||||
});
|
||||
|
||||
it("extracts structured questions", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "output_text", text: "I have a question." },
|
||||
{
|
||||
type: "question",
|
||||
prompt: "Which model?",
|
||||
choices: [
|
||||
{ key: "pro", label: "Gemini Pro", description: "Better" },
|
||||
{ key: "flash", label: "Gemini Flash" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const parsed = parseGeminiJsonl(stdout);
|
||||
expect(parsed.summary).toBe("I have a question.");
|
||||
expect(parsed.question).toEqual({
|
||||
prompt: "Which model?",
|
||||
choices: [
|
||||
{ key: "pro", label: "Gemini Pro", description: "Better" },
|
||||
{ key: "flash", label: "Gemini Flash", description: undefined },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("gemini_local stale session detection", () => {
|
||||
|
||||
@@ -77,7 +77,6 @@ describe("gemini execute", () => {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
model: "gemini-2.5-pro",
|
||||
yolo: true,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
@@ -112,6 +111,51 @@ describe("gemini execute", () => {
|
||||
);
|
||||
expect(invocationPrompt).toContain("Paperclip runtime note:");
|
||||
expect(invocationPrompt).toContain("PAPERCLIP_API_URL");
|
||||
expect(invocationPrompt).toContain("Paperclip API access note:");
|
||||
expect(invocationPrompt).toContain("run_shell_command");
|
||||
expect(result.question).toBeNull();
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("always passes --approval-mode yolo", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-yolo-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "gemini");
|
||||
const capturePath = path.join(root, "capture.json");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFakeGeminiCommand(commandPath);
|
||||
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
|
||||
try {
|
||||
await execute({
|
||||
runId: "run-yolo",
|
||||
agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
|
||||
},
|
||||
context: {},
|
||||
authToken: "t",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.argv).toContain("--approval-mode");
|
||||
expect(capture.argv).toContain("yolo");
|
||||
expect(capture.argv).not.toContain("--policy");
|
||||
expect(capture.argv).not.toContain("--allow-all");
|
||||
expect(capture.argv).not.toContain("--allow-read");
|
||||
} finally {
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
agentWakeupRequests,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
costEvents,
|
||||
issues,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
@@ -22,6 +21,7 @@ import { getServerAdapter, runningProcesses } from "../adapters/index.js";
|
||||
import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec } from "../adapters/index.js";
|
||||
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
|
||||
import { costService } from "./costs.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
||||
import { summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js";
|
||||
@@ -1029,8 +1029,8 @@ export function heartbeatService(db: Db) {
|
||||
.where(eq(agentRuntimeState.agentId, agent.id));
|
||||
|
||||
if (additionalCostCents > 0 || hasTokenUsage) {
|
||||
await db.insert(costEvents).values({
|
||||
companyId: agent.companyId,
|
||||
const costs = costService(db);
|
||||
await costs.createEvent(agent.companyId, {
|
||||
agentId: agent.id,
|
||||
provider: result.provider ?? "unknown",
|
||||
model: result.model ?? "unknown",
|
||||
@@ -1040,16 +1040,6 @@ export function heartbeatService(db: Db) {
|
||||
occurredAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
if (additionalCostCents > 0) {
|
||||
await db
|
||||
.update(agents)
|
||||
.set({
|
||||
spentMonthlyCents: sql`${agents.spentMonthlyCents} + ${additionalCostCents}`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agents.id, agent.id));
|
||||
}
|
||||
}
|
||||
|
||||
async function startNextQueuedRunForAgent(agentId: string) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { AdapterConfigFieldsProps } from "../types";
|
||||
import {
|
||||
DraftInput,
|
||||
Field,
|
||||
ToggleField,
|
||||
} from "../../components/agent-config-primitives";
|
||||
import { ChoosePathButton } from "../../components/PathInstructionsModal";
|
||||
|
||||
@@ -45,20 +44,6 @@ export function GeminiLocalConfigFields({
|
||||
<ChoosePathButton />
|
||||
</div>
|
||||
</Field>
|
||||
<ToggleField
|
||||
label="Yolo mode"
|
||||
hint="Run Gemini with --approval-mode yolo for unattended operation."
|
||||
checked={
|
||||
isCreate
|
||||
? values!.dangerouslyBypassSandbox
|
||||
: eff("adapterConfig", "yolo", config.yolo === true)
|
||||
}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ dangerouslyBypassSandbox: v })
|
||||
: mark("adapterConfig", "yolo", v)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -587,6 +587,23 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-1">
|
||||
{(issue.createdByAgentId || issue.createdByUserId) && (
|
||||
<PropertyRow label="Created by">
|
||||
{issue.createdByAgentId ? (
|
||||
<Link
|
||||
to={`/agents/${issue.createdByAgentId}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
<Identity name={agentName(issue.createdByAgentId) ?? issue.createdByAgentId.slice(0, 8)} size="sm" />
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm">{creatorUserLabel ?? "User"}</span>
|
||||
</>
|
||||
)}
|
||||
</PropertyRow>
|
||||
)}
|
||||
{issue.startedAt && (
|
||||
<PropertyRow label="Started">
|
||||
<span className="text-sm">{formatDate(issue.startedAt)}</span>
|
||||
|
||||
@@ -104,23 +104,12 @@ export function Layout() {
|
||||
|
||||
const togglePanel = togglePanelVisible;
|
||||
|
||||
// Cmd+1..9 to switch companies
|
||||
const switchCompany = useCallback(
|
||||
(index: number) => {
|
||||
if (index < companies.length) {
|
||||
setSelectedCompanyId(companies[index]!.id);
|
||||
}
|
||||
},
|
||||
[companies, setSelectedCompanyId],
|
||||
);
|
||||
|
||||
useCompanyPageMemory();
|
||||
|
||||
useKeyboardShortcuts({
|
||||
onNewIssue: () => openNewIssue(),
|
||||
onToggleSidebar: toggleSidebar,
|
||||
onTogglePanel: togglePanel,
|
||||
onSwitchCompany: switchCompany,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -699,7 +699,12 @@ export function NewIssueDialog() {
|
||||
}}
|
||||
readOnly={createIssue.isPending}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
!e.metaKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.nativeEvent.isComposing
|
||||
) {
|
||||
e.preventDefault();
|
||||
descriptionEditorRef.current?.focus();
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export const defaultCreateValues: CreateConfigValues = {
|
||||
model: "",
|
||||
thinkingEffort: "",
|
||||
chrome: false,
|
||||
dangerouslySkipPermissions: false,
|
||||
dangerouslySkipPermissions: true,
|
||||
search: false,
|
||||
dangerouslyBypassSandbox: false,
|
||||
command: "",
|
||||
|
||||
@@ -4,10 +4,9 @@ interface ShortcutHandlers {
|
||||
onNewIssue?: () => void;
|
||||
onToggleSidebar?: () => void;
|
||||
onTogglePanel?: () => void;
|
||||
onSwitchCompany?: (index: number) => void;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePanel, onSwitchCompany }: ShortcutHandlers) {
|
||||
export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePanel }: ShortcutHandlers) {
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// Don't fire shortcuts when typing in inputs
|
||||
@@ -16,13 +15,6 @@ export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePane
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+1..9 → Switch company
|
||||
if ((e.metaKey || e.ctrlKey) && e.key >= "1" && e.key <= "9") {
|
||||
e.preventDefault();
|
||||
onSwitchCompany?.(parseInt(e.key, 10) - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// C → New Issue
|
||||
if (e.key === "c" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
@@ -44,5 +36,5 @@ export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePane
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onNewIssue, onToggleSidebar, onTogglePanel, onSwitchCompany]);
|
||||
}, [onNewIssue, onToggleSidebar, onTogglePanel]);
|
||||
}
|
||||
|
||||
@@ -1313,7 +1313,7 @@ export function DesignGuide() {
|
||||
["C", "New Issue (outside inputs)"],
|
||||
["[", "Toggle Sidebar"],
|
||||
["]", "Toggle Properties Panel"],
|
||||
["Cmd+1..9 / Ctrl+1..9", "Switch Company (by rail order)"],
|
||||
|
||||
["Cmd+Enter / Ctrl+Enter", "Submit markdown comment"],
|
||||
].map(([key, desc]) => (
|
||||
<div key={key} className="flex items-center justify-between px-4 py-2">
|
||||
|
||||
Reference in New Issue
Block a user