Skip to content

Commit 251b6e7

Browse files
fix(control): crash and state-leak fixes in Control API commands, lint clean
- workspace.upload: the conflict handler now dies with its command; one left on the transfer manager answered a later upload's conflicts and started a second upload chain over the same queue item (empty-queue removeFirst). - editor.read: clamp maxChars to the document length so maxChars * 4 cannot overflow into a negative range that Scintilla reads as "to the end". - ControlApi: command hooks hold a QPointer, so an async command answered at shutdown no longer logs into a freed ControlApi. - workspace.unzip / workspace.receive: reset the zip conflict policy when the command ends, so a failed one cannot overwrite files in the user's next extraction; setConflictPolicy clears the previous lastConflicts. - AcpSessionView: skip chat event publishes without a session model. - clang-tidy: const QJsonArray locals in range-for (no detach, no implicit conversion), Arg spec functions instead of throwing static initializers, shared_ptr slot parameter by const reference. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 885fab7 commit 251b6e7

10 files changed

Lines changed: 110 additions & 70 deletions

‎src/FolderZipTransfer.h‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,14 @@ class FolderZipTransfer : public QObject
5252
// How extraction treats files that already exist. Ask shows the conflict
5353
// dialog (the tree's Zip > Upload); the others answer it for a caller that
5454
// already knows (the Control API). Refuse cancels and keeps the paths in
55-
// lastConflicts(). Applies to the next upload only, then resets to Ask.
55+
// lastConflicts(). Applies to the next upload only, then resets to Ask; a
56+
// caller whose upload fails before extraction must reset it to Ask itself.
5657
enum class ConflictPolicy : std::uint8_t { Ask, Overwrite, Skip, Refuse };
57-
void setConflictPolicy(ConflictPolicy policy) { m_conflictPolicy = policy; }
58+
void setConflictPolicy(ConflictPolicy policy)
59+
{
60+
m_conflictPolicy = policy;
61+
m_lastConflicts.clear();
62+
}
5863
QStringList lastConflicts() const { return m_lastConflicts; }
5964
void setRemoteBackend(remote::RemoteFsBackend *backend);
6065
void reportZipProgress(int current, int total, const QString &currentFile);

‎src/control/ControlApi.cpp‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -592,10 +592,17 @@ void ControlApi::execute(const Command &command, QJsonObject args, Respond respo
592592
if (command.changes) {
593593
const QJsonObject logged = loggedArgs(command, args);
594594
const QString label = stepLabel(command, args);
595-
hooks.finished = [this, name = command.name, label, logged](bool ok, const QString &error) {
596-
logAction(name, QString(), label, ok, error, logged);
595+
// Async handlers can answer after this object is gone (an AI dock destroyed after
596+
// MainWindow's children deleted ControlApi at shutdown), so never capture a raw this.
597+
const QPointer<ControlApi> api(this);
598+
hooks.finished = [api, name = command.name, label, logged](bool ok, const QString &error) {
599+
if (api)
600+
api->logAction(name, QString(), label, ok, error, logged);
601+
};
602+
hooks.reveal = [api](QWidget *w) {
603+
if (api)
604+
api->reveal(w);
597605
};
598-
hooks.reveal = [this](QWidget *w) { reveal(w); };
599606
emit commandRan(label);
600607
}
601608
const CommandContext ctx(&command, std::move(args), std::move(hooks));

‎src/control/commands/AiCommands.cpp‎

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ QJsonObject sessionJson(AiAgentDock *dock, const QString &sessionId, bool detail
284284
QVector<GrillMeForm::Answer> answersArg(const QJsonArray &array)
285285
{
286286
QVector<GrillMeForm::Answer> answers;
287-
for (const QJsonValue &v : array) {
287+
for (const auto &v : array) {
288288
const QJsonObject o = v.toObject();
289289
GrillMeForm::Answer a;
290290
a.questionId = o.value(QStringLiteral("id")).toString();
@@ -418,8 +418,11 @@ QJsonArray templatesJson(const GoalAgentSettings &s)
418418
return templates;
419419
}
420420

421-
const Arg kSession = Registrar::optional(QStringLiteral("session"), ArgType::String,
422-
QStringLiteral("session id from ai.sessions; default: the session in front"));
421+
Arg sessionArgSpec()
422+
{
423+
return Registrar::optional(QStringLiteral("session"), ArgType::String,
424+
QStringLiteral("session id from ai.sessions; default: the session in front"));
425+
}
423426

424427
} // namespace
425428

@@ -574,7 +577,7 @@ void Registrar::ai()
574577
add(QStringLiteral("ai.state"),
575578
QStringLiteral("Read a session's state: working, waiting for an answer (permission / form), models, modes, "
576579
"queue"),
577-
false, {kSession}, [sessionArg, idOf](const CommandContext &ctx) {
580+
false, {sessionArgSpec()}, [sessionArg, idOf](const CommandContext &ctx) {
578581
AiAgentDock *dock = nullptr;
579582
AcpSessionView *view = sessionArg(ctx, &dock);
580583
if (!view)
@@ -586,7 +589,7 @@ void Registrar::ai()
586589
add(QStringLiteral("ai.debugLog"),
587590
QStringLiteral("Read a session's debug log, as its Debug log window shows it (agent env values are hidden)"),
588591
false,
589-
{kSession, optional(QStringLiteral("last"), ArgType::Int, QStringLiteral("the last this many lines; default 200")),
592+
{sessionArgSpec(), optional(QStringLiteral("last"), ArgType::Int, QStringLiteral("the last this many lines; default 200")),
590593
optional(QStringLiteral("goalOnly"), ArgType::Bool, QStringLiteral("only the goal agent's lines"))},
591594
[sessionArg](const CommandContext &ctx) {
592595
AiAgentDock *dock = nullptr;
@@ -615,7 +618,7 @@ void Registrar::ai()
615618
});
616619

617620
add(QStringLiteral("ai.read"), QStringLiteral("Read a session's transcript: prompts, replies and tool calls"), false,
618-
{kSession, optional(QStringLiteral("last"), ArgType::Int, QStringLiteral("the last this many entries; default 30")),
621+
{sessionArgSpec(), optional(QStringLiteral("last"), ArgType::Int, QStringLiteral("the last this many entries; default 30")),
619622
optional(QStringLiteral("from"), ArgType::Int, QStringLiteral("instead: entries from this index")),
620623
optional(QStringLiteral("count"), ArgType::Int, QStringLiteral("with `from`: how many; default 30")),
621624
optional(QStringLiteral("thoughts"), ArgType::Bool, QStringLiteral("include the agent's thinking; default false"))},
@@ -684,7 +687,7 @@ void Registrar::ai()
684687
});
685688

686689
add(QStringLiteral("ai.report"), QStringLiteral("Read the final answer of a session's last finished turn"), false,
687-
{kSession}, [sessionArg, idOf](const CommandContext &ctx) {
690+
{sessionArgSpec()}, [sessionArg, idOf](const CommandContext &ctx) {
688691
AiAgentDock *dock = nullptr;
689692
AcpSessionView *view = sessionArg(ctx, &dock);
690693
if (!view)
@@ -708,7 +711,7 @@ void Registrar::ai()
708711
add(QStringLiteral("ai.send"),
709712
QStringLiteral("Send a prompt from a session's composer (queued behind a running turn; slash commands work)"),
710713
true,
711-
{kSession, required(QStringLiteral("text"), ArgType::String, QStringLiteral("the prompt")),
714+
{sessionArgSpec(), required(QStringLiteral("text"), ArgType::String, QStringLiteral("the prompt")),
712715
optional(QStringLiteral("images"), ArgType::Array,
713716
QStringLiteral("images to attach: [{path} (png/jpg/gif/webp; relative to the session folder) or "
714717
"{data: base64, mime: \"image/png\"}]")),
@@ -758,7 +761,7 @@ void Registrar::ai()
758761

759762
add(QStringLiteral("ai.draft"),
760763
QStringLiteral("Put text into a session's composer without sending it (what Send to AI does)"), true,
761-
{kSession, required(QStringLiteral("text"), ArgType::String, QStringLiteral("text to put in")),
764+
{sessionArgSpec(), required(QStringLiteral("text"), ArgType::String, QStringLiteral("text to put in")),
762765
optional(QStringLiteral("append"), ArgType::Bool,
763766
QStringLiteral("insert at the composer's caret (default true); false replaces the composer text"))},
764767
[sessionArg, showSession, idOf](const CommandContext &ctx) {
@@ -778,7 +781,7 @@ void Registrar::ai()
778781
QStringLiteral("Rewrite the composer's prompt with AI (the composer's Improve button) and leave the result "
779782
"there"),
780783
true,
781-
{kSession, optional(QStringLiteral("text"), ArgType::String,
784+
{sessionArgSpec(), optional(QStringLiteral("text"), ArgType::String,
782785
QStringLiteral("put this in the composer first; default: what it holds"))},
783786
[sessionArg, showSession, idOf](const CommandContext &ctx) {
784787
AiAgentDock *dock = nullptr;
@@ -817,7 +820,7 @@ void Registrar::ai()
817820
});
818821

819822
add(QStringLiteral("ai.queue"), QStringLiteral("List the prompts queued behind a session's running turn"), false,
820-
{kSession}, [sessionArg, idOf](const CommandContext &ctx) {
823+
{sessionArgSpec()}, [sessionArg, idOf](const CommandContext &ctx) {
821824
AiAgentDock *dock = nullptr;
822825
AcpSessionView *view = sessionArg(ctx, &dock);
823826
if (!view)
@@ -831,7 +834,7 @@ void Registrar::ai()
831834

832835
add(QStringLiteral("ai.unqueue"),
833836
QStringLiteral("Remove a queued prompt before it is sent (the queue strip's remove button)"), true,
834-
{kSession, required(QStringLiteral("index"), ArgType::Int, QStringLiteral("its index in ai.queue"))},
837+
{sessionArgSpec(), required(QStringLiteral("index"), ArgType::Int, QStringLiteral("its index in ai.queue"))},
835838
[sessionArg, showSession, idOf](const CommandContext &ctx) {
836839
AiAgentDock *dock = nullptr;
837840
AcpSessionView *view = sessionArg(ctx, &dock);
@@ -850,7 +853,7 @@ void Registrar::ai()
850853

851854
add(QStringLiteral("ai.stopTask"),
852855
QStringLiteral("Stop one of a session's background tasks (ai.state lists them under tasks)"), true,
853-
{kSession, required(QStringLiteral("taskId"), ArgType::String, QStringLiteral("task id from ai.state"))},
856+
{sessionArgSpec(), required(QStringLiteral("taskId"), ArgType::String, QStringLiteral("task id from ai.state"))},
854857
[sessionArg, showSession, idOf](const CommandContext &ctx) {
855858
AiAgentDock *dock = nullptr;
856859
AcpSessionView *view = sessionArg(ctx, &dock);
@@ -878,7 +881,7 @@ void Registrar::ai()
878881

879882
add(QStringLiteral("ai.retry"),
880883
QStringLiteral("Restart a session whose agent exited (the chat's Retry); a running agent has nothing to retry"),
881-
true, {kSession}, [w = w, sessionArg, showSession, idOf](const CommandContext &ctx) {
884+
true, {sessionArgSpec()}, [w = w, sessionArg, showSession, idOf](const CommandContext &ctx) {
882885
AiAgentDock *dock = nullptr;
883886
AcpSessionView *view = sessionArg(ctx, &dock);
884887
if (!view)
@@ -899,7 +902,7 @@ void Registrar::ai()
899902
});
900903

901904
add(QStringLiteral("ai.cancel"), QStringLiteral("Stop a session's running turn (and a goal running on it)"), true,
902-
{kSession}, [sessionArg, showSession, idOf](const CommandContext &ctx) {
905+
{sessionArgSpec()}, [sessionArg, showSession, idOf](const CommandContext &ctx) {
903906
AiAgentDock *dock = nullptr;
904907
AcpSessionView *view = sessionArg(ctx, &dock);
905908
if (!view)
@@ -912,7 +915,7 @@ void Registrar::ai()
912915

913916
add(QStringLiteral("ai.set"), QStringLiteral("Pick a session's model, mode or reasoning effort (ai.state lists them)"),
914917
true,
915-
{kSession, optional(QStringLiteral("model"), ArgType::String, QStringLiteral("model id")),
918+
{sessionArgSpec(), optional(QStringLiteral("model"), ArgType::String, QStringLiteral("model id")),
916919
optional(QStringLiteral("mode"), ArgType::String, QStringLiteral("mode id")),
917920
optional(QStringLiteral("effort"), ArgType::String, QStringLiteral("effort id")),
918921
optional(QStringLiteral("prewalk"), ArgType::String,
@@ -965,7 +968,7 @@ void Registrar::ai()
965968

966969
add(QStringLiteral("ai.autoApprove"),
967970
QStringLiteral("Turn auto-approve of agent tool permissions on or off (applies to every session)"), true,
968-
{required(QStringLiteral("on"), ArgType::Bool, QStringLiteral("true: agents run tools without asking")), kSession},
971+
{required(QStringLiteral("on"), ArgType::Bool, QStringLiteral("true: agents run tools without asking")), sessionArgSpec()},
969972
[sessionArg, showSession, idOf](const CommandContext &ctx) {
970973
AiAgentDock *dock = nullptr;
971974
AcpSessionView *view = sessionArg(ctx, &dock);
@@ -978,7 +981,7 @@ void Registrar::ai()
978981

979982
add(QStringLiteral("ai.permission"),
980983
QStringLiteral("Answer the permission request a session is waiting on (ai.state shows its options)"), true,
981-
{kSession, required(QStringLiteral("option"), ArgType::String,
984+
{sessionArgSpec(), required(QStringLiteral("option"), ArgType::String,
982985
QStringLiteral("option id or kind: allow_once, allow_always, reject_once, ..."))},
983986
[sessionArg, showSession, idOf](const CommandContext &ctx) {
984987
AiAgentDock *dock = nullptr;
@@ -1005,7 +1008,7 @@ void Registrar::ai()
10051008

10061009
add(QStringLiteral("ai.answer"),
10071010
QStringLiteral("Answer the question form a session shows (ai.state has its questions) and submit it"), true,
1008-
{kSession,
1011+
{sessionArgSpec(),
10091012
required(QStringLiteral("answers"), ArgType::Array,
10101013
QStringLiteral("[{id, value, other?, note?, clarify?}]: value is an option value (single/select), an "
10111014
"array of them (multi), \"accept\"/\"change\" (proposal, a change in `other`), "
@@ -1034,7 +1037,7 @@ void Registrar::ai()
10341037
});
10351038

10361039
add(QStringLiteral("ai.dismissForm"), QStringLiteral("Dismiss the question form a session shows"), true,
1037-
{kSession, optional(QStringLiteral("note"), ArgType::String, QStringLiteral("a note for the agent"))},
1040+
{sessionArgSpec(), optional(QStringLiteral("note"), ArgType::String, QStringLiteral("a note for the agent"))},
10381041
[sessionArg, showSession, idOf](const CommandContext &ctx) {
10391042
AiAgentDock *dock = nullptr;
10401043
AcpSessionView *view = sessionArg(ctx, &dock);
@@ -1049,7 +1052,7 @@ void Registrar::ai()
10491052
ctx.ok(sessionJson(dock, id, false));
10501053
});
10511054

1052-
add(QStringLiteral("ai.focus"), QStringLiteral("Bring a session to the front"), true, {kSession},
1055+
add(QStringLiteral("ai.focus"), QStringLiteral("Bring a session to the front"), true, {sessionArgSpec()},
10531056
[sessionArg, showSession, idOf](const CommandContext &ctx) {
10541057
AiAgentDock *dock = nullptr;
10551058
AcpSessionView *view = sessionArg(ctx, &dock);
@@ -1061,7 +1064,7 @@ void Registrar::ai()
10611064
});
10621065

10631066
add(QStringLiteral("ai.close"), QStringLiteral("Close a session (its agent stops; history is kept)"), true,
1064-
{kSession, optional(QStringLiteral("force"), ArgType::Bool, QStringLiteral("close even while it works"))},
1067+
{sessionArgSpec(), optional(QStringLiteral("force"), ArgType::Bool, QStringLiteral("close even while it works"))},
10651068
[w = w, sessionArg, idOf](const CommandContext &ctx) {
10661069
AiAgentDock *dock = nullptr;
10671070
AcpSessionView *view = sessionArg(ctx, &dock);
@@ -1080,7 +1083,7 @@ void Registrar::ai()
10801083

10811084
add(QStringLiteral("ai.restart"),
10821085
QStringLiteral("Restart a session's agent with a fresh conversation in the same chat slot"), true,
1083-
{kSession, optional(QStringLiteral("force"), ArgType::Bool, QStringLiteral("restart even while it works"))},
1086+
{sessionArgSpec(), optional(QStringLiteral("force"), ArgType::Bool, QStringLiteral("restart even while it works"))},
10841087
[w = w, sessionArg, idOf, showSession](const CommandContext &ctx) {
10851088
AiAgentDock *dock = nullptr;
10861089
AcpSessionView *view = sessionArg(ctx, &dock);
@@ -1104,7 +1107,7 @@ void Registrar::ai()
11041107
add(QStringLiteral("ai.switchAgent"),
11051108
QStringLiteral("Move an idle local Claude Code session to another Claude Code agent, keeping the conversation"),
11061109
true,
1107-
{kSession, required(QStringLiteral("agent"), ArgType::String, QStringLiteral("target agent id from ai.agents"))},
1110+
{sessionArgSpec(), required(QStringLiteral("agent"), ArgType::String, QStringLiteral("target agent id from ai.agents"))},
11081111
[w = w, sessionArg, idOf, showSession](const CommandContext &ctx) {
11091112
AiAgentDock *dock = nullptr;
11101113
AcpSessionView *view = sessionArg(ctx, &dock);
@@ -1623,7 +1626,7 @@ void Registrar::goal()
16231626
QStringLiteral("Run a goal on a session: send a prompt (or keep going in the conversation) until each success "
16241627
"criterion is met"),
16251628
true,
1626-
{kSession,
1629+
{sessionArgSpec(),
16271630
required(QStringLiteral("criteria"), ArgType::Array, QStringLiteral("success criteria, checked in order")),
16281631
optional(QStringLiteral("prompt"), ArgType::String,
16291632
QStringLiteral("first prompt; default: the composer text, else the goal attaches to the conversation")),
@@ -1677,7 +1680,7 @@ void Registrar::goal()
16771680
});
16781681

16791682
add(QStringLiteral("goal.state"), QStringLiteral("Read the goal running on a session: criteria, iterations, verdicts"),
1680-
false, {kSession}, [sessionOf](const CommandContext &ctx) {
1683+
false, {sessionArgSpec()}, [sessionOf](const CommandContext &ctx) {
16811684
AiAgentDock *dock = nullptr;
16821685
QString id;
16831686
if (!sessionOf(ctx, &dock, &id))
@@ -1707,7 +1710,7 @@ void Registrar::goal()
17071710
});
17081711

17091712
add(QStringLiteral("goal.stop"), QStringLiteral("Stop the goal running on a session (the turn keeps going)"), true,
1710-
{kSession}, [sessionOf](const CommandContext &ctx) {
1713+
{sessionArgSpec()}, [sessionOf](const CommandContext &ctx) {
17111714
AiAgentDock *dock = nullptr;
17121715
QString id;
17131716
if (!sessionOf(ctx, &dock, &id))
@@ -1727,7 +1730,7 @@ void Registrar::goal()
17271730
QStringLiteral("Write a prompt from success criteria with the judge agent (Improve on an empty composer) and "
17281731
"put it in the session's composer"),
17291732
true,
1730-
{kSession, required(QStringLiteral("criteria"), ArgType::Array, QStringLiteral("the success criteria")),
1733+
{sessionArgSpec(), required(QStringLiteral("criteria"), ArgType::Array, QStringLiteral("the success criteria")),
17311734
optional(QStringLiteral("judgeAgent"), ArgType::String, QStringLiteral("agent that writes it; default from goal.options")),
17321735
optional(QStringLiteral("template"), ArgType::String, QStringLiteral("prompt template id; default from goal.options"))},
17331736
[w = w, sessionOf](const CommandContext &ctx) {

‎src/control/commands/CsvCommands.cpp‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,8 @@ void Registrar::csv()
215215
const quint64 from = quint64(std::max<qint64>(0, ctx.integer(QStringLiteral("row"))));
216216
const qint64 count = std::clamp<qint64>(ctx.integer(QStringLiteral("count"), 100), 0, kCsvReadMax);
217217
QList<int> columns;
218-
for (const QJsonValue &v : ctx.array(QStringLiteral("columns"))) {
218+
const QJsonArray columnsJson = ctx.array(QStringLiteral("columns"));
219+
for (const auto &v : columnsJson) {
219220
const int c = v.toInt(-1);
220221
if (c < 0 || c >= model->columnCount()) {
221222
ctx.fail(400, QStringLiteral("there is no column %1 (%2 columns)").arg(c).arg(model->columnCount()));
@@ -280,7 +281,8 @@ void Registrar::csv()
280281
if (!csv || !csvReady(ctx, csv))
281282
return;
282283
QList<quint64> rows;
283-
for (const QJsonValue &v : ctx.array(QStringLiteral("rows"))) {
284+
const QJsonArray rowsJson = ctx.array(QStringLiteral("rows"));
285+
for (const auto &v : rowsJson) {
284286
if (!v.isDouble() || v.toDouble() < 0) {
285287
ctx.fail(400, QStringLiteral("rows must be 0-based row numbers"));
286288
return;
@@ -312,7 +314,8 @@ void Registrar::csv()
312314
if (!csv || !csvReady(ctx, csv))
313315
return;
314316
QList<int> cols;
315-
for (const QJsonValue &v : ctx.array(QStringLiteral("columns"))) {
317+
const QJsonArray columnsJson = ctx.array(QStringLiteral("columns"));
318+
for (const auto &v : columnsJson) {
316319
if (!v.isDouble() || v.toInt(-1) < 0) {
317320
ctx.fail(400, QStringLiteral("columns must be 0-based column numbers"));
318321
return;
@@ -341,7 +344,8 @@ void Registrar::csv()
341344
if (!csv || !csvReady(ctx, csv))
342345
return;
343346
QList<QPair<quint64, int>> cells;
344-
for (const QJsonValue &v : ctx.array(QStringLiteral("cells"))) {
347+
const QJsonArray cellsJson = ctx.array(QStringLiteral("cells"));
348+
for (const auto &v : cellsJson) {
345349
const QJsonObject o = v.toObject();
346350
const qint64 row = qint64(o.value(QStringLiteral("row")).toDouble(-1));
347351
const int col = o.value(QStringLiteral("column")).toInt(-1);

0 commit comments

Comments
 (0)