Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5eae570
fix(read_file): normalize empty pages string to prevent validation loop
Gucc111 Jun 2, 2026
05cad8d
feat(always-on): add maxPlansPerCycle config to cap plans per work cycle
Gucc111 Jun 2, 2026
7dd00c7
feat(telemetry): add telemetry.enabled toggle to YAML config and Sett…
Gucc111 Jun 3, 2026
ef5b57a
fix(config): sync rawRef before setState to prevent save() reading st…
Gucc111 Jun 3, 2026
0e22d0e
feat(telemetry): drive telemetry toggle from YAML config with hot-reload
Gucc111 Jun 3, 2026
7370a11
fix(always-on): extract report from LLM text when report tool is not …
Gucc111 Jun 4, 2026
d987297
fix(config): deep-compare arrays in config diff to eliminate reload f…
Gucc111 Jun 4, 2026
12f0514
fix(proxy): bypass proxy for gateway WebSocket and remove dead proxyP…
Gucc111 Jun 4, 2026
8d8f86d
feat(proxy): promote proxy config to top-level field with Gateway hot…
Gucc111 Jun 4, 2026
c1473d4
fix(proxy): eliminate race condition in installGlobalProxy (#137)
Gucc111 Jun 4, 2026
1076559
fix(ui): add missing Always-On phase entries to prevent fallback to D…
Gucc111 Jun 4, 2026
e3f40d4
chore: remove test file from PR
Gucc111 Jun 4, 2026
92600c0
fix(always-on): clear activeWorkCycleId from state after archive/apply
Gucc111 Jun 5, 2026
652562f
fix(mcp): set browser-use CWD to output dir and read screenshot files…
Gucc111 Jun 5, 2026
92ab221
Update Always-On event badge behavior
Gucc111 Jun 8, 2026
6b11785
Remove Always-On badge UI test
Gucc111 Jun 8, 2026
676cc64
Merge PR #114: fix(read_file): normalize empty pages string to preven…
Gucc111 Jun 8, 2026
0f17f76
Merge PR #154: fix(config): deep-compare arrays in config diff to eli…
Gucc111 Jun 8, 2026
de9b49e
Merge PR #164: fix(always-on): clear activeWorkCycleId from state aft…
Gucc111 Jun 8, 2026
b697bc9
Merge PR #166: fix(mcp): browser-use screenshot saving path and inlin…
Gucc111 Jun 8, 2026
fde9767
Merge PR #115: feat(always-on): add maxPlansPerCycle config to cap pl…
Gucc111 Jun 8, 2026
3748df5
Merge PR #153: fix(always-on): extract report from LLM text when repo…
Gucc111 Jun 8, 2026
bed12a3
Merge PR #156: fix(ui): add missing Always-On phase entries to preven…
Gucc111 Jun 8, 2026
4e578ea
Merge PR #176: [codex] Update Always-On event badge behavior
Gucc111 Jun 8, 2026
416839f
Merge PR #150: feat(telemetry): add UI toggle and YAML-driven hot-rel…
Gucc111 Jun 8, 2026
aa7bf43
Merge PR #158: fix(proxy): bypass localhost, promote to top-level con…
Gucc111 Jun 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/always-on/config/parseAlwaysOnConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type AlwaysOnWorkspaceConfig = {
snapshotBaseDir?: string;
snapshotMaxBytes: number;
gitLfs: boolean;
maxPlansPerCycle: number;
};

export type AlwaysOnExecutionConfig = {
Expand Down Expand Up @@ -78,6 +79,7 @@ export function defaultAlwaysOnConfig(): AlwaysOnConfig {
workspace: {
snapshotMaxBytes: DEFAULT_SNAPSHOT_MAX_BYTES,
gitLfs: false,
maxPlansPerCycle: 3,
},
execution: {
maxTurns: 30,
Expand Down Expand Up @@ -340,6 +342,12 @@ function parseWorkspace(
diagnostics,
);
target.gitLfs = booleanField(raw, "gitLfs", target.gitLfs);
target.maxPlansPerCycle = positiveInteger(
raw.maxPlansPerCycle,
target.maxPlansPerCycle,
"alwaysOn.workspace.maxPlansPerCycle",
diagnostics,
);
}

function parseExecution(
Expand Down
2 changes: 1 addition & 1 deletion src/always-on/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ export {
type WebPlanContextRefs,
type WebPlanStatus,
} from "./web/DiscoveryPlanStatus.js";
export { DiscoveryPlanService, normalizeDiscoveryPlanRecord, type DiscoveryPlanServiceDeps } from "./web/DiscoveryPlanService.js";
export { DiscoveryPlanService, normalizeDiscoveryPlanRecord, type DiscoveryPlanServiceDeps, type StateManager } from "./web/DiscoveryPlanService.js";
export { buildDiscoveryContext, type DiscoveryContextDeps } from "./web/DiscoveryPlanContext.js";
export { AlwaysOnRunHistoryService, type AlwaysOnRunHistoryServiceDeps, type RunHistoryEntry, type RunHistoryDetailEntry } from "./web/AlwaysOnRunHistoryService.js";
export { GitWorktreeProvider, type GitWorktreeProviderOptions } from "./workspace/GitWorktreeProvider.js";
Expand Down
3 changes: 2 additions & 1 deletion src/always-on/protocol/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,8 @@ export type GateBlockReason =
| "recent_user_msg"
| "cooldown"
| "daily_budget"
| "lock_busy";
| "lock_busy"
| "cycle_full";

export type GateResult =
| { ok: true; lease?: AlwaysOnChannelLease }
Expand Down
1 change: 1 addition & 0 deletions src/always-on/runtime/AlwaysOnRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export class AlwaysOnRuntime {
projectKey: this.projectKey,
paths: this.paths,
stateStore: this.stateStore,
cycleStore: this.cycleStore,
leases: this.leases,
fire: this.fire,
uuid: this.uuid,
Expand Down
60 changes: 55 additions & 5 deletions src/always-on/runtime/DiscoveryFire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { Gateway, GatewayChannelKey, GatewayEvent } from "../../gateway/ind
import { getPilotProjectChatDir } from "../../pilot/paths.js";
import { buildChatDigest } from "../context/ChatDigestBuilder.js";
import type { AlwaysOnConfig } from "../config/parseAlwaysOnConfig.js";
import { buildFallbackReport, type ReportMetadata } from "../contracts/ReportContract.js";
import { buildFallbackReport, parseReportMarkdown, type ReportMetadata } from "../contracts/ReportContract.js";
import { AlwaysOnError } from "../protocol/errors.js";
import type {
AlwaysOnDiscoveryOutcome,
Expand Down Expand Up @@ -458,24 +458,44 @@ export class DiscoveryFire {
};
this.deps.runContexts.register(reportCtx);

let reportEvents: GatewayEvent[] = [];
let reportError: { code?: string; message: string } | undefined;
try {
const events = await this.drainTurn({
reportEvents = await this.drainTurn({
sessionKey: reportSessionKey,
channelKey: REPORT_CHANNEL,
runId: `${runId}.report`,
message: buildReportPrompt({ plan: planRecord, planMarkdown, workspaceCwd: workspace.cwd, workspaceStrategy: workspace.strategy, language: this.deps.config.language }),
mode: "bypassPermissions",
persistEvents: true,
});
reportError = pickFirstError(events);
reportError = pickFirstError(reportEvents);
} finally {
this.deps.runContexts.unregister(reportSessionKey);
this.deps.sessionOverrides.delete(reportSessionKey);
await this.deps.gateway.closeSession({ sessionKey: reportSessionKey, reason: "always-on/done" }).catch(() => undefined);
}

const finishedAt = this.deps.now();

if (!reportCtx.report && !reportError) {
const assistantText = extractAssistantText(reportEvents);
if (assistantText) {
const metadata: ReportMetadata = {
runId,
planId,
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
outcome: "executed",
workspaceStrategy: workspace.strategy === "git-worktree" ? "git-worktree" : "snapshot-copy",
workspaceHandle: workspace.cwd,
};
const parsed = parseReportMarkdown(assistantText, metadata);
const filePath = await this.deps.reportStore.writeReport(runId, parsed.rawContent);
reportCtx.report = { markdown: parsed.rawContent, filePath, finishedAt };
}
}

const outcome: AlwaysOnDiscoveryOutcome = reportCtx.report && !reportError ? "executed" : "failed";

if (reportCtx.report && !reportError) {
Expand Down Expand Up @@ -801,9 +821,10 @@ export class DiscoveryFire {
};
this.deps.runContexts.register(reportCtx);

let reportEvents: GatewayEvent[] = [];
let reportError: { code?: string; message: string } | undefined;
try {
const events = await this.drainTurn({
reportEvents = await this.drainTurn({
sessionKey: reportSessionKey,
channelKey: REPORT_CHANNEL,
runId: `${runId}.report`,
Expand All @@ -817,7 +838,7 @@ export class DiscoveryFire {
mode: "bypassPermissions",
persistEvents: true,
});
reportError = pickFirstError(events);
reportError = pickFirstError(reportEvents);
} finally {
this.deps.runContexts.unregister(reportSessionKey);
this.deps.sessionOverrides.delete(reportSessionKey);
Expand All @@ -827,6 +848,25 @@ export class DiscoveryFire {
}

const finishedAt = this.deps.now();

if (!reportCtx.report && !reportError) {
const assistantText = extractAssistantText(reportEvents);
if (assistantText) {
const metadata: ReportMetadata = {
runId,
planId: planRecord.id,
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
outcome: "executed",
workspaceStrategy: workspace.strategy === "git-worktree" ? "git-worktree" : "snapshot-copy",
workspaceHandle: workspace.cwd,
};
const parsed = parseReportMarkdown(assistantText, metadata);
const filePath = await this.deps.reportStore.writeReport(runId, parsed.rawContent);
reportCtx.report = { markdown: parsed.rawContent, filePath, finishedAt };
}
}

const outcome: AlwaysOnDiscoveryOutcome = reportCtx.report && !reportError ? "executed" : "failed";

if (reportCtx.report && !reportError) {
Expand Down Expand Up @@ -1116,3 +1156,13 @@ function pickFirstError(events: GatewayEvent[]): { code?: string; message: strin
}
return undefined;
}

function extractAssistantText(events: GatewayEvent[]): string {
let text = "";
for (const event of events) {
if (event.type === "assistant_text_delta") {
text += event.text;
}
}
return text.trim();
}
14 changes: 14 additions & 0 deletions src/always-on/runtime/DiscoveryScheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AlwaysOnError } from "../protocol/errors.js";
import type { GateBlockReason } from "../protocol/types.js";
import type { AlwaysOnPaths } from "../storage/AlwaysOnPaths.js";
import { DiscoveryStateStore } from "../storage/DiscoveryStateStore.js";
import { WorkCycleStore } from "../storage/WorkCycleStore.js";
import type { ChannelLeaseRegistry } from "./ChannelLeaseRegistry.js";
import {
acquireDiscoveryLock,
Expand All @@ -23,6 +24,7 @@ export type DiscoverySchedulerDependencies = {
projectKey: string;
paths: AlwaysOnPaths;
stateStore: DiscoveryStateStore;
cycleStore: WorkCycleStore;
leases: ChannelLeaseRegistry;
fire: DiscoveryFire;
uuid: () => string;
Expand Down Expand Up @@ -113,6 +115,18 @@ export class DiscoveryScheduler {
return { outcome: "blocked", reason: evaluation.reason };
}

if (state.activeWorkCycleId) {
const activeCycle = await this.deps.cycleStore.getRecord(state.activeWorkCycleId);
if (
activeCycle &&
activeCycle.status === "active" &&
activeCycle.planIds.length >= this.deps.config.workspace.maxPlansPerCycle
) {
this.deps.logger.info("always-on gate blocked", { reason: "cycle_full" });
return { outcome: "blocked", reason: "cycle_full" as GateBlockReason };
}
}

const runId = this.deps.uuid();
const startedAt = this.deps.now();

Expand Down
21 changes: 21 additions & 0 deletions src/always-on/web/DiscoveryPlanService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ export type WorkspaceManager = {
): Promise<void>;
};

export type StateManager = {
clearActiveWorkCycleId(projectRoot: string): Promise<void>;
};

export type DiscoveryPlanServiceDeps = {
pilotHome: string;
createProjectId: (projectRoot: string) => string;
Expand All @@ -111,6 +115,7 @@ export type DiscoveryPlanServiceDeps = {
activity: SessionActivityChecker;
events: RunEventSink;
workspace?: WorkspaceManager;
state?: StateManager;
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -395,6 +400,14 @@ export class DiscoveryPlanService {
}
await writePlanStore(projectDir, store);

if (this.deps.state) {
try {
await this.deps.state.clearActiveWorkCycleId(projectRoot);
} catch {
// Best effort — state cleanup should not block archive.
}
}

return { archived: true };
}

Expand Down Expand Up @@ -500,6 +513,14 @@ export class DiscoveryPlanService {
}
}
await writePlanStore(projectDir, store);

if (this.deps.state) {
try {
await this.deps.state.clearActiveWorkCycleId(projectRoot);
} catch {
// Best effort — state cleanup should not block apply finalization.
}
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/cli/createLocalGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -760,7 +760,7 @@ class ProjectRuntimeRegistry {
sanitizeSessionIdForPath(context.sessionKey),
);
mkdirSyncFs(outDir, { recursive: true });
return { ...spec, args: [...(spec.args ?? []), `--output-dir=${outDir}`] };
return { ...spec, cwd: outDir, args: [...(spec.args ?? []), `--output-dir=${outDir}`] };
}
return spec;
});
Expand Down
26 changes: 23 additions & 3 deletions src/cli/pilotdeck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ import {
import { loadPilotConfig, resolvePilotHome } from "../pilot/index.js";
import { createLocalGateway } from "./createLocalGateway.js";
import { startPilotDeckServer } from "./pilotdeckServer.js";
import { installGlobalProxy } from "./proxy.js";
import { installGlobalProxy, reinstallGlobalProxy } from "./proxy.js";
import { createShutdownAndExit } from "./shutdownCoordinator.js";
import { createTelemetryCollector } from "../telemetry/index.js";

installGlobalProxy();
await installGlobalProxy();

async function main(argv = process.argv.slice(2)): Promise<void> {
const command = argv[0];
Expand All @@ -26,7 +26,16 @@ async function main(argv = process.argv.slice(2)): Promise<void> {
const env = process.env;
const pilotHome = resolvePilotHome(env);
const snapshot = loadPilotConfig({ projectRoot, env });
const telemetry = createTelemetryCollector({ env, pilotHome });
const telemetry = createTelemetryCollector({
env, pilotHome,
enabled: snapshot.config.telemetry?.enabled,
});

// Apply proxy from config (env-based proxy from top-level installGlobalProxy
// takes precedence; this fills in when only pilotdeck.yaml has a proxy).
if (snapshot.config.proxy?.url) {
await installGlobalProxy(snapshot.config.proxy.url);
}

let alwaysOn: AlwaysOnManager | undefined;
let cron: CronRuntime | undefined;
Expand Down Expand Up @@ -128,8 +137,19 @@ async function main(argv = process.argv.slice(2)): Promise<void> {
let reloadChain = Promise.resolve();

configStore.subscribe((event) => {
if (event.changedPaths.some((p) => p.startsWith("telemetry."))) {
telemetry.setEnabled(event.nextSnapshot.config.telemetry?.enabled ?? false);
}

const aoChanged = event.changedPaths.some((p) => p.startsWith("alwaysOn."));
const cronChanged = event.changedPaths.some((p) => p.startsWith("cron."));
const proxyChanged = event.changedPaths.some((p) => p.startsWith("proxy.") || p === "proxy");

if (proxyChanged) {
const proxyConfig = event.nextSnapshot.config.proxy;
void reinstallGlobalProxy(proxyConfig?.url, proxyConfig?.noProxy);
}

if (!aoChanged && !cronChanged) return;

reloadChain = reloadChain
Expand Down
Loading