Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/compaction-recovery-pointer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Point compacted conversation notes at the on-disk event log so later turns can recover exact outputs.
5 changes: 5 additions & 0 deletions .changeset/compaction-resume-anchor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

After context compaction, keep a short continue-work instruction as the latest user message.
5 changes: 5 additions & 0 deletions .changeset/file-history-turn-snapshots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": minor
---

Add turn-level file history for Edit and Write. Clients can list a turn's file changes and read captured content through the session file-history API.
5 changes: 5 additions & 0 deletions .changeset/notify-user-updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": minor
---

Add an experimental NotifyUser tool that posts mid-turn updates in a TUI Updates panel. Enable it with PYTHINKER_CODE_EXPERIMENTAL_NOTIFY_USER=1, `[experimental] notify_user = true`, or `/experiments`.
5 changes: 5 additions & 0 deletions .changeset/print-flush-and-fork-cutoff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Keep print-mode shutdown from skipping journal flush, and keep truncated forks from dropping later work in the retained turn.
5 changes: 5 additions & 0 deletions .changeset/print-flush-journals-on-exit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Keep print-mode session journals complete when the process exits or receives a termination signal.
5 changes: 5 additions & 0 deletions .changeset/read-character-budgets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": minor
---

Read large files in pages with a character budget instead of a 1000-line or 100 KB cap. Set `[read] default_max_chars` and `[read] max_chars` in config.toml, or pass `max_chars` and `column_offset` on Read.
5 changes: 5 additions & 0 deletions .changeset/session-rating-survey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": minor
---

Add an occasional session rating prompt above the editor. Turn it off with `disable_feedback_survey = true` in `tui.toml` or Settings → Feedback survey.
4 changes: 3 additions & 1 deletion apps/pythinker-code/src/cli/run-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
withTelemetryContext,
} from '@pymodel/pythinker-telemetry';

import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app';
import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, TUI_HOST_UI_CAPABILITIES } from '#/constant/app';
import type { TuiConfig } from '#/tui/config';
import { loadTuiConfig, TuiConfigParseError } from '#/tui/config';
import { CHROME_GUTTER } from '#/tui/constant/rendering';
Expand Down Expand Up @@ -66,6 +66,7 @@ export async function runShell(
homeDir: telemetryBootstrap.homeDir,
identity: createPythinkerCodeHostIdentity(version),
skillDirs: opts.skillsDirs,
uiCapabilities: TUI_HOST_UI_CAPABILITIES,
telemetry: telemetryClient,
sessionStartedProperties: { yolo: opts.yolo, auto: opts.auto, plan: opts.plan, afk: false },
};
Expand Down Expand Up @@ -104,6 +105,7 @@ export async function runShell(
workDir,
startupNotice: configWarning,
engineV2,
telemetryDisabled: config.telemetry === false,
});

initializeCliTelemetry({
Expand Down
175 changes: 170 additions & 5 deletions apps/pythinker-code/src/cli/v2/run-v2-print.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
AgentCron,
AgentGoal,
IAgentLifecycleService,
IAgentLoopService,
IAgentPermissionModeService,
IAgentProfileService,
IAgentPromptService,
Expand All @@ -30,6 +31,7 @@ import {
IBootstrapService,
IConfigService,
IEventBus,
IEventDispatcher,
IHostFileSystem,
ISessionIndex,
IWorkspaceInstanceManager,
Expand Down Expand Up @@ -125,6 +127,8 @@ import {
const PROMPT_UI_MODE = 'print';
/** Re-check `goalActive` at least this often while waiting for goal turns. */
const GOAL_WAIT_POLL_MS = 250;
/** Re-check each agent's prompt queue while waiting for it to drain at exit. */
const PROMPT_QUIESCE_POLL_MS = 10;
/**
* Slack on top of a scheduled cron fire time while waiting for the steered
* turn: covers the 1s tick poll interval plus fire → inject → turn-launch
Expand Down Expand Up @@ -197,23 +201,37 @@ export async function runV2Print(
}

let restorePermission = async (): Promise<void> => {};
let quiesceAgents = async (_signal?: AbortSignal): Promise<void> => {};
let releaseQuiescence: (() => void) | undefined;
let flushWires = async (): Promise<void> => {};
let removeTerminationCleanup: (() => void) | undefined;
let cleanupPromise: Promise<void> | undefined;
let telemetryService: ITelemetryService | undefined;
const cleanup = async (): Promise<void> => {
const pending = (cleanupPromise ??= (async () => {
removeTerminationCleanup?.();
setCrashPhase('shutdown');
const quiesceAbort = new AbortController();
try {
await restorePermission();
await raceWithTimeout(quiesceAgents(quiesceAbort.signal), CLI_SHUTDOWN_TIMEOUT_MS).catch(
() => {},
);
} finally {
quiesceAbort.abort();
try {
if (telemetryService !== undefined) {
await raceWithTimeout(telemetryService.shutdown(), CLI_SHUTDOWN_TIMEOUT_MS);
}
} finally {
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }).catch(() => {});
await Promise.all([
raceWithTimeout(flushWires(), CLI_SHUTDOWN_TIMEOUT_MS).catch(() => {}),
telemetryService !== undefined
? raceWithTimeout(telemetryService.shutdown(), CLI_SHUTDOWN_TIMEOUT_MS).catch(
() => {},
)
: Promise.resolve(),
shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }).catch(() => {}),
]);
app.dispose();
} finally {
releaseQuiescence?.();
}
}
})());
Expand Down Expand Up @@ -262,6 +280,10 @@ export async function runV2Print(

const resolved = await resolveNativeSession(app, opts, workDir, defaultModel, stderr);
restorePermission = resolved.restorePermission;
quiesceAgents = async (signal) => {
releaseQuiescence = await quiesceSessionAgents(resolved.session, resolved.agent, signal);
};
flushWires = () => flushSessionWires(resolved.session, resolved.agent);

telemetryService.setContext({ sessionId: resolved.session.id, model: resolved.telemetryModel });
setTelemetryContext({ sessionId: resolved.session.id });
Expand Down Expand Up @@ -935,6 +957,149 @@ function countPendingBackgroundTasks(session: ISessionScopeHandle): number {
return count;
}

function collectSessionAgentHandles(
session: ISessionScopeHandle,
mainAgent: IAgentScopeHandle,
): IAgentScopeHandle[] {
const agentManager = session.accessor.get(IAgentLifecycleService);
const handles = new Set<IAgentScopeHandle>([mainAgent]);
for (const agent of agentManager.list()) {
const handle = agentManager.handleOf(agent.agentId);
if (handle !== undefined) handles.add(handle);
}
return [...handles];
}

export function abortPromise(signal: AbortSignal | undefined): Promise<void> {
if (signal === undefined) return new Promise(() => {});
if (signal.aborted) return Promise.resolve();
return new Promise((resolve) => {
signal.addEventListener('abort', () => resolve(), { once: true });
});
}

export async function settleOrAbort(
signal: AbortSignal | undefined,
work: Promise<unknown>,
): Promise<void> {
if (signal?.aborted) return;
await Promise.race([work.then(() => undefined, () => undefined), abortPromise(signal)]);
}

export async function delayOrAbort(signal: AbortSignal | undefined, ms: number): Promise<void> {
if (signal?.aborted) return;
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
const onAbort = (): void => {
clearTimeout(timer);
resolve();
};
signal?.addEventListener('abort', onAbort, { once: true });
});
}

async function quiesceSessionAgents(
session: ISessionScopeHandle,
mainAgent: IAgentScopeHandle,
signal?: AbortSignal,
): Promise<(() => void) | undefined> {
const handles = collectSessionAgentHandles(session, mainAgent);
const promptServices = handles.flatMap((handle) => {
try {
return [handle.accessor.get(IAgentPromptService)];
} catch {
return [];
}
});
const loops = handles.flatMap((handle) => {
try {
return [handle.accessor.get(IAgentLoopService)];
} catch {
return [];
}
});
await settleOrAbort(
signal,
Promise.allSettled(
handles.flatMap((handle) => {
try {
return [handle.accessor.get(IAgentTaskService).stopAllOnExit('Session closed')];
} catch {
return [];
}
}),
),
);
for (;;) {
if (signal?.aborted) return undefined;
await settleOrAbort(
signal,
Promise.allSettled(promptServices.map((service) => service.drain())),
);
if (signal?.aborted) return undefined;
for (const loop of loops) {
for (const turnId of loop.status().pendingTurnIds) loop.cancel(turnId);
loop.cancel();
}
await settleOrAbort(
signal,
Promise.allSettled(loops.map((loop) => loop.settled())),
);
const guards: { dispose(): void }[] = [];
let frozen = true;
for (const loop of loops) {
let guard: { dispose(): void } | undefined;
try {
guard = loop.tryAcquireQuiescence();
} catch {
continue;
}
if (guard === undefined) {
frozen = false;
break;
}
guards.push(guard);
}
const busy = promptServices.some((service) => {
try {
const snapshot = service.list();
return snapshot.launching || snapshot.active !== undefined || snapshot.pending.length > 0;
} catch {
return false;
}
});
if (signal?.aborted) {
for (const guard of guards) guard.dispose();
return undefined;
}
if (frozen && !busy) {
return () => {
for (const guard of guards) guard.dispose();
};
}
for (const guard of guards) guard.dispose();
await delayOrAbort(signal, PROMPT_QUIESCE_POLL_MS);
}
}

async function flushSessionWires(
session: ISessionScopeHandle,
mainAgent: IAgentScopeHandle,
): Promise<void> {
await Promise.allSettled(
collectSessionAgentHandles(session, mainAgent).flatMap((handle) => {
try {
return [handle.accessor.get(IEventDispatcher).flush()];
} catch {
return [];
}
}),
);
}

async function drainBackgroundTasks(
session: ISessionScopeHandle,
ceilingS: number | undefined,
Expand Down
4 changes: 3 additions & 1 deletion apps/pythinker-code/src/constant/app.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ErrorCodes } from '@pymodel/pythinker-code-sdk';
import { ErrorCodes, type HostUiCapability } from '@pymodel/pythinker-code-sdk';

export const PRODUCT_NAME = 'Pythinker Code';
export const CLI_COMMAND_NAME = 'pythinker';
Expand All @@ -7,6 +7,7 @@ export const PROCESS_NAME = 'pythinker-code';
// Used in telemetry app names and HTTP User-Agent headers.
export const CLI_USER_AGENT_PRODUCT = 'pythinker-code-cli';
export const CLI_UI_MODE = 'shell';
export const TUI_HOST_UI_CAPABILITIES: readonly HostUiCapability[] = ['update_panel'];
// Telemetry ui_mode for the `pythinker web` host. Same product
// as the CLI (CLI_USER_AGENT_PRODUCT); the surface is distinguished by ui_mode.
export const WEB_UI_MODE = 'web';
Expand Down Expand Up @@ -78,6 +79,7 @@ export const PYTHINKER_CODE_UPDATE_REEXEC_ENV = 'PYTHINKER_CODE_UPDATE_REEXEC';
export const PYTHINKER_CODE_INPUT_HISTORY_DIR_NAME = 'user-history';
export const PYTHINKER_CODE_BANNER_DIR_NAME = 'banner';
export const PYTHINKER_CODE_BANNER_STATE_FILE_NAME = 'state.json';
export const PYTHINKER_CODE_SURVEY_STATE_FILE_NAME = 'feedback-survey-state.json';

// SDK/core error code that tells the TUI to show a login-required startup
// notice. Derived from sdk's ErrorCodes so a future rename in core
Expand Down
Loading
Loading