@gajae-code/coding-agent 0.12.12 → 0.12.14
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.
- package/CHANGELOG.md +32 -0
- package/dist/types/cli/marketplace-hint.d.ts +28 -0
- package/dist/types/cli.d.ts +10 -0
- package/dist/types/config/file-lock-gc.d.ts +13 -1
- package/dist/types/config/model-registry.d.ts +6 -0
- package/dist/types/config/models-config-schema.d.ts +99 -0
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +14 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +11 -0
- package/dist/types/modes/acp/acp-agent.d.ts +24 -1
- package/dist/types/sdk/bus/html-format.d.ts +10 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -2
- package/dist/types/sdk/bus/telegram-daemon.d.ts +15 -1
- package/dist/types/sdk/bus/telegram-reference.d.ts +10 -0
- package/dist/types/session/agent-session.d.ts +0 -2
- package/dist/types/session/session-manager.d.ts +17 -1
- package/dist/types/tools/index.d.ts +7 -0
- package/package.json +8 -8
- package/src/cli/fast-help.ts +1 -0
- package/src/cli/marketplace-hint.ts +39 -0
- package/src/cli/plugin-cli.ts +10 -0
- package/src/cli.ts +23 -0
- package/src/config/file-lock-gc.ts +47 -26
- package/src/config/model-profiles.ts +18 -18
- package/src/config/model-registry.ts +17 -5
- package/src/config/models-config-schema.ts +9 -4
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
- package/src/gjc-runtime/deep-interview-recorder.ts +9 -5
- package/src/gjc-runtime/deep-interview-state.ts +27 -3
- package/src/gjc-runtime/gc-render.ts +8 -1
- package/src/gjc-runtime/gc-runtime.ts +15 -1
- package/src/internal-urls/docs-index.generated.ts +4 -4
- package/src/main.ts +11 -3
- package/src/modes/acp/acp-agent.ts +123 -7
- package/src/modes/components/model-selector.ts +52 -5
- package/src/modes/print-mode.ts +10 -0
- package/src/prompts/system/project-prompt.md +19 -0
- package/src/sdk/bus/html-format.ts +19 -3
- package/src/sdk/bus/index.ts +21 -12
- package/src/sdk/bus/telegram-daemon-contract.ts +6 -2
- package/src/sdk/bus/telegram-daemon.ts +50 -16
- package/src/sdk/bus/telegram-reference.ts +72 -9
- package/src/sdk/session.ts +5 -1
- package/src/session/agent-session.ts +0 -4
- package/src/session/internal/managed-session-storage.ts +24 -2
- package/src/session/session-manager.ts +77 -22
- package/src/setup/provider-presets.json +3 -3
- package/src/thinking-metadata.ts +2 -1
- package/src/tools/ask.ts +10 -0
- package/src/tools/index.ts +7 -0
- package/src/tools/todo-write.ts +4 -4
package/src/main.ts
CHANGED
|
@@ -61,11 +61,13 @@ import { SessionMigrationBusyError } from "./session/internal/session-open-error
|
|
|
61
61
|
import {
|
|
62
62
|
type ResumeSessionIdentity,
|
|
63
63
|
resolveResumableSession,
|
|
64
|
+
SESSION_OVERSIZED_RECOVERY_MESSAGE,
|
|
64
65
|
SessionArtifactCapacityError,
|
|
65
66
|
type SessionDestination,
|
|
66
67
|
type SessionDirectoryMigrationPolicy,
|
|
67
68
|
type SessionInfo,
|
|
68
69
|
SessionManager,
|
|
70
|
+
SessionTranscriptOversizedError,
|
|
69
71
|
type StrictSessionOpenResult,
|
|
70
72
|
} from "./session/session-manager";
|
|
71
73
|
import { runStartupCredentialAutoImportIfNeeded } from "./setup/credential-auto-import";
|
|
@@ -620,10 +622,13 @@ function operatorFacingSessionOpenMessage(value: unknown): string | undefined {
|
|
|
620
622
|
? value.code
|
|
621
623
|
: value instanceof SessionMigrationBusyError
|
|
622
624
|
? value.code
|
|
623
|
-
:
|
|
624
|
-
? value
|
|
625
|
-
:
|
|
625
|
+
: value instanceof SessionTranscriptOversizedError
|
|
626
|
+
? value.code
|
|
627
|
+
: typeof value === "string"
|
|
628
|
+
? value
|
|
629
|
+
: undefined;
|
|
626
630
|
if (code === "artifact_capacity_exceeded") return SESSION_ARTIFACT_CAPACITY_RECOVERY_MESSAGE;
|
|
631
|
+
if (code === "oversized") return SESSION_OVERSIZED_RECOVERY_MESSAGE;
|
|
627
632
|
if (code === "migration_busy") return new SessionMigrationBusyError().message;
|
|
628
633
|
return undefined;
|
|
629
634
|
}
|
|
@@ -1834,6 +1839,9 @@ export async function runRootCommand(
|
|
|
1834
1839
|
} catch {
|
|
1835
1840
|
logger.warn("Failed to dispose session after interactive error");
|
|
1836
1841
|
}
|
|
1842
|
+
if (error !== null && typeof error === "object" && "code" in error && error.code === "cancelled") {
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1837
1845
|
throw error;
|
|
1838
1846
|
}
|
|
1839
1847
|
|
|
@@ -403,8 +403,8 @@ function receivedSdkEvent(frame: JsonObject): ReceivedSdkEvent | undefined {
|
|
|
403
403
|
}
|
|
404
404
|
|
|
405
405
|
const ACP_CONFIG_OPTIONS = [
|
|
406
|
-
{ id: MODEL_CONFIG_ID, name: "Model", options: [] },
|
|
407
|
-
{ id: THINKING_CONFIG_ID, name: "Thinking", options: [] },
|
|
406
|
+
{ id: MODEL_CONFIG_ID, name: "Model", category: "model", options: [] },
|
|
407
|
+
{ id: THINKING_CONFIG_ID, name: "Thinking", category: "thought_level", options: [] },
|
|
408
408
|
{
|
|
409
409
|
id: "steeringMode",
|
|
410
410
|
name: "Steering queue",
|
|
@@ -466,17 +466,122 @@ function modelPresetConfigOptions(query: unknown, current: string): { value: str
|
|
|
466
466
|
return [...options].map(([value, name]) => ({ value, name }));
|
|
467
467
|
}
|
|
468
468
|
|
|
469
|
-
function modelConfigOptions(
|
|
469
|
+
function modelConfigOptions(
|
|
470
|
+
query: unknown,
|
|
471
|
+
current: string | undefined,
|
|
472
|
+
activeProviders?: ReadonlySet<string>,
|
|
473
|
+
): { value: string; name: string }[] {
|
|
470
474
|
const options = new Map<string, string>();
|
|
471
475
|
for (const item of pageItems(query)) {
|
|
472
476
|
const model = object(item);
|
|
473
477
|
if (!model || typeof model.provider !== "string" || typeof model.id !== "string") continue;
|
|
478
|
+
if (activeProviders !== undefined && !activeProviders.has(model.provider)) continue;
|
|
474
479
|
const value = `${model.provider}/${model.id}`;
|
|
475
480
|
options.set(value, typeof model.name === "string" ? model.name : value);
|
|
476
481
|
}
|
|
477
482
|
if (current && !options.has(current)) options.set(current, current);
|
|
478
483
|
return [...options].map(([value, name]) => ({ value, name }));
|
|
479
484
|
}
|
|
485
|
+
const MAX_ACTIVE_PROVIDER_PAGES = 100;
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Unsupported-query compatibility fallback. Session hosts without
|
|
489
|
+
* `providers.list/active` reject the unknown named query as either
|
|
490
|
+
* `operation_not_session_owned` (host knows the registry but not the query)
|
|
491
|
+
* or `invalid_request` (pre-Q29 host that predates the registry entry). Both
|
|
492
|
+
* keep the full catalog authoritative on the first page; every other failure
|
|
493
|
+
* mode fails closed so the active-provider contract is never silently
|
|
494
|
+
* widened.
|
|
495
|
+
*/
|
|
496
|
+
function isUnsupportedQueryError(error: unknown): boolean {
|
|
497
|
+
return (
|
|
498
|
+
typeof error === "object" &&
|
|
499
|
+
error !== null &&
|
|
500
|
+
((error as { code?: unknown }).code === "operation_not_session_owned" ||
|
|
501
|
+
(error as { code?: unknown }).code === "invalid_request")
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function queryPage(query: unknown): { items?: unknown; complete?: unknown; continuationCursor?: unknown } | undefined {
|
|
506
|
+
const response = object(query);
|
|
507
|
+
const result = object(response?.result) ?? response;
|
|
508
|
+
return object(result?.page);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Collect every page of `providers.list/active` (Q29, GJC >= 0.12.8) and
|
|
513
|
+
* return the providers with usable stored credentials or credentialless
|
|
514
|
+
* connection kinds, mirroring the TUI model picker's
|
|
515
|
+
* `modelRegistry.getAvailable()`. The openwebui-gjc-adapter applies the same
|
|
516
|
+
* filter to `/v1/models`. Q29 pages are byte-bounded and can span multiple
|
|
517
|
+
* pages when custom provider ids inflate the payload, so all pages are
|
|
518
|
+
* consumed before the provider set is built. Returns undefined only when the
|
|
519
|
+
* session host rejects the query with `operation_not_session_owned`; any
|
|
520
|
+
* other query failure or malformed page is thrown.
|
|
521
|
+
*/
|
|
522
|
+
export async function collectActiveProviderIds(
|
|
523
|
+
adapter: Pick<AcpSdkAdapter, "query">,
|
|
524
|
+
): Promise<ReadonlySet<string> | undefined> {
|
|
525
|
+
const providers = new Set<string>();
|
|
526
|
+
let cursor: string | undefined;
|
|
527
|
+
for (let pageCount = 0; pageCount < MAX_ACTIVE_PROVIDER_PAGES; pageCount++) {
|
|
528
|
+
let response: unknown;
|
|
529
|
+
try {
|
|
530
|
+
response = await adapter.query("providers.list/active", {}, cursor);
|
|
531
|
+
} catch (error) {
|
|
532
|
+
if (cursor === undefined && isUnsupportedQueryError(error)) return undefined;
|
|
533
|
+
throw error;
|
|
534
|
+
}
|
|
535
|
+
const page = queryPage(response);
|
|
536
|
+
if (!page) throw new AcpSdkAdapterError("protocol_error", "providers.list/active returned no page.");
|
|
537
|
+
const items = page.items;
|
|
538
|
+
if (!Array.isArray(items))
|
|
539
|
+
throw new AcpSdkAdapterError("protocol_error", "providers.list/active returned a malformed page.");
|
|
540
|
+
for (const item of items) {
|
|
541
|
+
const record = object(item);
|
|
542
|
+
if (!record || typeof record.provider !== "string") continue;
|
|
543
|
+
const connectionKind = record.connectionKind;
|
|
544
|
+
if (connectionKind === "credential" || connectionKind === "credentialless") providers.add(record.provider);
|
|
545
|
+
}
|
|
546
|
+
if (page.complete === true) return providers;
|
|
547
|
+
if (typeof page.continuationCursor !== "string")
|
|
548
|
+
throw new AcpSdkAdapterError(
|
|
549
|
+
"protocol_error",
|
|
550
|
+
"providers.list/active page is incomplete without a continuation cursor.",
|
|
551
|
+
);
|
|
552
|
+
cursor = page.continuationCursor;
|
|
553
|
+
}
|
|
554
|
+
throw new AcpSdkAdapterError("protocol_error", "providers.list/active exceeded the page budget.");
|
|
555
|
+
}
|
|
556
|
+
const MAX_MODEL_CATALOG_PAGES = 100;
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Collect every page of `models.list/current` (Q10) into one catalog so the
|
|
560
|
+
* active-provider filter never drops models that only appear on later pages.
|
|
561
|
+
* The SDK pages Q10 at a fixed byte target (256 KiB), which a fully
|
|
562
|
+
* configured catalog can exceed. Returns the same
|
|
563
|
+
* `{ result: { page: { items } } }` envelope `pageItems` consumes.
|
|
564
|
+
*/
|
|
565
|
+
export async function collectModelCatalog(adapter: Pick<AcpSdkAdapter, "query">): Promise<unknown> {
|
|
566
|
+
const items: unknown[] = [];
|
|
567
|
+
let cursor: string | undefined;
|
|
568
|
+
for (let pageCount = 0; pageCount < MAX_MODEL_CATALOG_PAGES; pageCount++) {
|
|
569
|
+
const response = await adapter.query("models.list/current", {}, cursor);
|
|
570
|
+
const page = queryPage(response);
|
|
571
|
+
if (!page) throw new AcpSdkAdapterError("protocol_error", "models.list/current returned no page.");
|
|
572
|
+
if (!Array.isArray(page.items))
|
|
573
|
+
throw new AcpSdkAdapterError("protocol_error", "models.list/current returned a malformed page.");
|
|
574
|
+
items.push(...page.items);
|
|
575
|
+
if (page.complete === true) return { result: { page: { items } } };
|
|
576
|
+
if (typeof page.continuationCursor !== "string")
|
|
577
|
+
throw new AcpSdkAdapterError(
|
|
578
|
+
"protocol_error",
|
|
579
|
+
"models.list/current page is incomplete without a continuation cursor.",
|
|
580
|
+
);
|
|
581
|
+
cursor = page.continuationCursor;
|
|
582
|
+
}
|
|
583
|
+
throw new AcpSdkAdapterError("protocol_error", "models.list/current exceeded the page budget.");
|
|
584
|
+
}
|
|
480
585
|
|
|
481
586
|
const THINKING_CONFIG_OPTIONS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"].map(value => ({
|
|
482
587
|
value,
|
|
@@ -484,7 +589,12 @@ const THINKING_CONFIG_OPTIONS = ["off", "minimal", "low", "medium", "high", "xhi
|
|
|
484
589
|
}));
|
|
485
590
|
|
|
486
591
|
/** Maps live canonical SDK config and the selected model catalog into the ACP 1.2.1 session state surface. */
|
|
487
|
-
export function acpSessionStateFromConfig(
|
|
592
|
+
export function acpSessionStateFromConfig(
|
|
593
|
+
query: unknown,
|
|
594
|
+
modelCatalogQuery?: unknown,
|
|
595
|
+
modelPreset?: string,
|
|
596
|
+
activeProviders?: ReadonlySet<string>,
|
|
597
|
+
) {
|
|
488
598
|
const values = configValues(query);
|
|
489
599
|
const useModelPresets = modelPreset !== undefined;
|
|
490
600
|
const currentModeId = values.get(MODE_CONFIG_ID) === ACP_PLAN_MODE_ID ? ACP_PLAN_MODE_ID : ACP_DEFAULT_MODE_ID;
|
|
@@ -493,6 +603,7 @@ export function acpSessionStateFromConfig(query: unknown, modelCatalogQuery?: un
|
|
|
493
603
|
{
|
|
494
604
|
id: MODE_CONFIG_ID,
|
|
495
605
|
name: "Mode",
|
|
606
|
+
category: "mode" as const,
|
|
496
607
|
type: "select" as const,
|
|
497
608
|
currentValue: currentModeId,
|
|
498
609
|
options: [
|
|
@@ -510,7 +621,7 @@ export function acpSessionStateFromConfig(query: unknown, modelCatalogQuery?: un
|
|
|
510
621
|
option.id === MODEL_CONFIG_ID
|
|
511
622
|
? useModelPresets
|
|
512
623
|
? modelPresetConfigOptions(modelCatalogQuery, value)
|
|
513
|
-
: modelConfigOptions(modelCatalogQuery, value)
|
|
624
|
+
: modelConfigOptions(modelCatalogQuery, value, activeProviders)
|
|
514
625
|
: option.id === THINKING_CONFIG_ID
|
|
515
626
|
? THINKING_CONFIG_OPTIONS
|
|
516
627
|
: [...option.options];
|
|
@@ -1799,8 +1910,13 @@ export class AcpAgent implements Agent {
|
|
|
1799
1910
|
const modelPreset = this.#startupOptions?.modelPreset;
|
|
1800
1911
|
const [config, modelCatalog] = await Promise.all([
|
|
1801
1912
|
record.adapter.query("config.list/get"),
|
|
1802
|
-
|
|
1913
|
+
modelPreset === undefined ? collectModelCatalog(record.adapter) : record.adapter.query("models.profiles.list"),
|
|
1803
1914
|
]);
|
|
1915
|
+
// Resolve usable providers in parallel. Only an older session host that
|
|
1916
|
+
// rejects `providers.list/active` with `operation_not_session_owned`
|
|
1917
|
+
// falls back to the full catalog; operational failures fail closed so
|
|
1918
|
+
// the active-provider contract is not silently widened.
|
|
1919
|
+
const activeProviders = modelPreset === undefined ? await collectActiveProviderIds(record.adapter) : undefined;
|
|
1804
1920
|
record.authFailure = undefined;
|
|
1805
1921
|
if (modelPreset !== undefined) {
|
|
1806
1922
|
const activePreset = configValues(config).get(MODEL_PRESET_CONFIG_KEY);
|
|
@@ -1815,7 +1931,7 @@ export class AcpAgent implements Agent {
|
|
|
1815
1931
|
throw new AcpSdkAdapterError("authentication_failed", record.authFailure);
|
|
1816
1932
|
}
|
|
1817
1933
|
}
|
|
1818
|
-
return acpSessionStateFromConfig(config, modelCatalog, modelPreset);
|
|
1934
|
+
return acpSessionStateFromConfig(config, modelCatalog, modelPreset, activeProviders);
|
|
1819
1935
|
}
|
|
1820
1936
|
|
|
1821
1937
|
async #publishAvailableCommands(id: string, adapter: AcpSdkAdapter): Promise<void> {
|
|
@@ -1721,9 +1721,13 @@ export class ModelSelectorComponent extends Container {
|
|
|
1721
1721
|
: choice.role === null
|
|
1722
1722
|
? "temporary model"
|
|
1723
1723
|
: GJC_MODEL_ASSIGNMENT_TARGETS[choice.role].name;
|
|
1724
|
+
// Show the highlighted reasoning level — never the model id (that mislabel
|
|
1725
|
+
// made "Reasoning for Default: gpt-5.6-luna" look like a level).
|
|
1726
|
+
const selectedLevel = choice.levels[this.#selectedThinkingIndex];
|
|
1727
|
+
const selectedLevelLabel = selectedLevel ? getThinkingLevelMetadata(selectedLevel).label : "—";
|
|
1724
1728
|
this.#listContainer.addChild(new Spacer(1));
|
|
1725
1729
|
this.#listContainer.addChild(
|
|
1726
|
-
new Text(theme.fg("muted", ` Reasoning for ${targetLabel}: ${
|
|
1730
|
+
new Text(theme.fg("muted", ` Reasoning for ${targetLabel}: ${selectedLevelLabel}`), 0, 0),
|
|
1727
1731
|
);
|
|
1728
1732
|
this.#listContainer.addChild(new Spacer(1));
|
|
1729
1733
|
for (let i = 0; i < choice.levels.length; i++) {
|
|
@@ -2079,8 +2083,13 @@ export class ModelSelectorComponent extends Container {
|
|
|
2079
2083
|
this.#updateList();
|
|
2080
2084
|
}
|
|
2081
2085
|
}
|
|
2082
|
-
#getInitialThinkingChoiceIndex(
|
|
2083
|
-
|
|
2086
|
+
#getInitialThinkingChoiceIndex(
|
|
2087
|
+
item: ModelItem | CanonicalModelItem,
|
|
2088
|
+
levels: ThinkingLevel[],
|
|
2089
|
+
role: GjcModelAssignmentTargetId | null = null,
|
|
2090
|
+
roles?: readonly GjcModelAssignmentTargetId[],
|
|
2091
|
+
): number {
|
|
2092
|
+
const preferred = this.#getPreferredThinkingLevel(item, role, roles);
|
|
2084
2093
|
if (preferred && preferred !== ThinkingLevel.Inherit) {
|
|
2085
2094
|
const index = levels.indexOf(preferred);
|
|
2086
2095
|
if (index !== -1) return index;
|
|
@@ -2088,13 +2097,51 @@ export class ModelSelectorComponent extends Container {
|
|
|
2088
2097
|
return 0;
|
|
2089
2098
|
}
|
|
2090
2099
|
|
|
2091
|
-
|
|
2100
|
+
/**
|
|
2101
|
+
* Prefer the role binding the user is editing when it already points at this
|
|
2102
|
+
* model so the reasoning menu opens on the level role badges already show
|
|
2103
|
+
* (e.g. EXECUTOR (xhigh) must not leave the cursor on "off").
|
|
2104
|
+
*/
|
|
2105
|
+
#getPreferredThinkingLevel(
|
|
2106
|
+
item: ModelItem | CanonicalModelItem,
|
|
2107
|
+
role: GjcModelAssignmentTargetId | null = null,
|
|
2108
|
+
roles?: readonly GjcModelAssignmentTargetId[],
|
|
2109
|
+
): ThinkingLevel | undefined {
|
|
2110
|
+
const roleThinking = this.#getAssignedThinkingLevelForModel(item.model, role, roles);
|
|
2111
|
+
if (roleThinking && roleThinking !== ThinkingLevel.Inherit) {
|
|
2112
|
+
return roleThinking;
|
|
2113
|
+
}
|
|
2092
2114
|
if (item.thinkingLevel && item.thinkingLevel !== ThinkingLevel.Inherit) {
|
|
2093
2115
|
return item.thinkingLevel;
|
|
2094
2116
|
}
|
|
2095
2117
|
return undefined;
|
|
2096
2118
|
}
|
|
2097
2119
|
|
|
2120
|
+
#getAssignedThinkingLevelForModel(
|
|
2121
|
+
model: Model,
|
|
2122
|
+
role: GjcModelAssignmentTargetId | null,
|
|
2123
|
+
roles?: readonly GjcModelAssignmentTargetId[],
|
|
2124
|
+
): ThinkingLevel | undefined {
|
|
2125
|
+
if (roles && roles.length > 0) {
|
|
2126
|
+
const assignedLevels = roles
|
|
2127
|
+
.map(targetRole => this.#roles[targetRole])
|
|
2128
|
+
.filter(
|
|
2129
|
+
(assigned): assigned is RoleAssignment =>
|
|
2130
|
+
assigned !== undefined &&
|
|
2131
|
+
modelsAreEqual(assigned.model, model) &&
|
|
2132
|
+
assigned.thinkingLevel !== ThinkingLevel.Inherit,
|
|
2133
|
+
)
|
|
2134
|
+
.map(assigned => assigned.thinkingLevel);
|
|
2135
|
+
if (assignedLevels.length === 0) return undefined;
|
|
2136
|
+
const first = assignedLevels[0];
|
|
2137
|
+
return assignedLevels.every(level => level === first) ? first : undefined;
|
|
2138
|
+
}
|
|
2139
|
+
if (role === null) return undefined;
|
|
2140
|
+
const assigned = this.#roles[role];
|
|
2141
|
+
if (!assigned || !modelsAreEqual(assigned.model, model)) return undefined;
|
|
2142
|
+
return assigned.thinkingLevel;
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2098
2145
|
#handleSelect(
|
|
2099
2146
|
item: ModelItem | CanonicalModelItem,
|
|
2100
2147
|
role: GjcModelAssignmentTargetId | null,
|
|
@@ -2109,7 +2156,7 @@ export class ModelSelectorComponent extends Container {
|
|
|
2109
2156
|
if (!hasExplicitThinkingChoice && needsExplicitThinkingChoice) {
|
|
2110
2157
|
const levels = getSelectableThinkingLevels(item.model);
|
|
2111
2158
|
this.#pendingThinkingChoice = { item, role, roles, levels };
|
|
2112
|
-
this.#selectedThinkingIndex = this.#getInitialThinkingChoiceIndex(item, levels);
|
|
2159
|
+
this.#selectedThinkingIndex = this.#getInitialThinkingChoiceIndex(item, levels, role, roles);
|
|
2113
2160
|
this.#updateList();
|
|
2114
2161
|
return;
|
|
2115
2162
|
}
|
package/src/modes/print-mode.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { type AssistantMessage, type ImageContent, isContextOverflow } from "@gajae-code/ai";
|
|
9
9
|
import { isKnownSinkPeerClosedError, logger, sanitizeText } from "@gajae-code/utils";
|
|
10
|
+
import { loadSlashCommands } from "../extensibility/slash-commands";
|
|
10
11
|
import type { AgentSession } from "../session/agent-session";
|
|
11
12
|
import { isSilentAbort } from "../session/messages";
|
|
12
13
|
import { initializeExtensions } from "./runtime-init";
|
|
@@ -207,6 +208,15 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
|
|
|
207
208
|
if (header) stdout.write(`${JSON.stringify(header)}\n`);
|
|
208
209
|
}
|
|
209
210
|
|
|
211
|
+
// Bundled and file-based slash commands (`/init`, project `commands/*.md`, …) expand
|
|
212
|
+
// inside AgentSession#prompt from the list handed over by setSlashCommands. Interactive
|
|
213
|
+
// mode loads it while building its autocomplete; print mode has no autocomplete, so
|
|
214
|
+
// without this the list stays empty and a leading-slash prompt reaches the model as
|
|
215
|
+
// literal prose — no expansion, no error, and an answer that looks like the command ran.
|
|
216
|
+
await logger.time("print:slash-commands", async () => {
|
|
217
|
+
session.setSlashCommands(await loadSlashCommands({ cwd: session.sessionManager.getCwd() }));
|
|
218
|
+
});
|
|
219
|
+
|
|
210
220
|
// Set up extensions for print mode (no UI, no command context).
|
|
211
221
|
await initializeExtensions(session, {
|
|
212
222
|
reportSendError: (action, err) => {
|
|
@@ -21,6 +21,25 @@ MUST read before making changes within:
|
|
|
21
21
|
{{#list agentsMdSearch.files join="\n"}}- {{this}}{{/list}}
|
|
22
22
|
</dir-context>
|
|
23
23
|
{{/if}}
|
|
24
|
+
|
|
25
|
+
{{#if alwaysApplyRules.length}}
|
|
26
|
+
{{#each alwaysApplyRules}}
|
|
27
|
+
{{content}}
|
|
28
|
+
{{/each}}
|
|
29
|
+
{{/if}}
|
|
30
|
+
{{#if rules.length}}
|
|
31
|
+
Rules are local constraints. You MUST read `rule://<name>` when working in that domain.
|
|
32
|
+
<rules>
|
|
33
|
+
{{#list rules join="\n"}}
|
|
34
|
+
<rule name="{{name}}">
|
|
35
|
+
{{description}}
|
|
36
|
+
{{#if globs.length}}
|
|
37
|
+
{{#list globs join="\n"}}<glob>{{this}}</glob>{{/list}}
|
|
38
|
+
{{/if}}
|
|
39
|
+
</rule>
|
|
40
|
+
{{/list}}
|
|
41
|
+
</rules>
|
|
42
|
+
{{/if}}
|
|
24
43
|
<critical>
|
|
25
44
|
- Each response MUST advance the task. There is no stopping condition other than completion.
|
|
26
45
|
- You MUST default to informed action; do not ask for confirmation when tools or repo context can answer.
|
|
@@ -427,6 +427,23 @@ export function finalizeTelegramHtml(message?: string): string | undefined {
|
|
|
427
427
|
return truncateTelegramHtml(message);
|
|
428
428
|
}
|
|
429
429
|
|
|
430
|
+
/** Multi-select state marker a transport may prepend to an option label. */
|
|
431
|
+
export const SELECTION_MARK_CHECKED = "☑";
|
|
432
|
+
export const SELECTION_MARK_UNCHECKED = "☐";
|
|
433
|
+
const SELECTION_MARK_PREFIX = new RegExp(`^\\s*([${SELECTION_MARK_CHECKED}${SELECTION_MARK_UNCHECKED}])\\s+`);
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Drop a leading `N.`/`N)` index already embedded in an option label (e.g.
|
|
437
|
+
* deep-interview options pre-numbered by the ask tool) so the canonical
|
|
438
|
+
* one-based index can be applied instead. A selection marker stays in front of
|
|
439
|
+
* the text: the label is renumbered, not un-marked.
|
|
440
|
+
*/
|
|
441
|
+
export function renumberableOptionText(label: string): string {
|
|
442
|
+
const marker = SELECTION_MARK_PREFIX.exec(label);
|
|
443
|
+
const rest = marker ? label.slice(marker[0].length) : label;
|
|
444
|
+
return `${marker ? `${marker[1]} ` : ""}${rest.replace(/^\s*\d+[.)]\s+/, "")}`;
|
|
445
|
+
}
|
|
446
|
+
|
|
430
447
|
/**
|
|
431
448
|
* One-based, plain-text button label (Telegram does not parse HTML in labels).
|
|
432
449
|
*
|
|
@@ -436,13 +453,12 @@ export function finalizeTelegramHtml(message?: string): string | undefined {
|
|
|
436
453
|
* `1. 1. …` and keeps the displayed number aligned with the button's real index.
|
|
437
454
|
*/
|
|
438
455
|
export function buttonLabel(label: string, index: number): string {
|
|
439
|
-
|
|
440
|
-
return `${index + 1}. ${stripped}`;
|
|
456
|
+
return `${index + 1}. ${renumberableOptionText(label)}`;
|
|
441
457
|
}
|
|
442
458
|
|
|
443
459
|
/** Numbered, escaped option list for the Telegram message body. */
|
|
444
460
|
export function numberedOptionList(labels: string[]): string {
|
|
445
|
-
return labels.map((label, i) => `${i + 1}. ${escapeHtml(label
|
|
461
|
+
return labels.map((label, i) => `${i + 1}. ${escapeHtml(renumberableOptionText(label))}`).join("\n");
|
|
446
462
|
}
|
|
447
463
|
|
|
448
464
|
/** Compact numeric button label; full option text belongs in the message body. */
|
package/src/sdk/bus/index.ts
CHANGED
|
@@ -837,16 +837,22 @@ export class PresentationArbiter {
|
|
|
837
837
|
...(presentation.recommendedIndex === undefined
|
|
838
838
|
? {}
|
|
839
839
|
: { recommendedIndex: presentation.recommendedIndex }),
|
|
840
|
-
controls
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
840
|
+
// A presentation that carries its own controls (the ask tool's
|
|
841
|
+
// Next/Done) keeps them; a durable workflow gate presents none,
|
|
842
|
+
// so its navigation control is synthesized here.
|
|
843
|
+
controls:
|
|
844
|
+
presentation.controls.length > 0
|
|
845
|
+
? presentation.controls
|
|
846
|
+
: presentation.multi
|
|
847
|
+
? [
|
|
848
|
+
{
|
|
849
|
+
id: "navigation_forward",
|
|
850
|
+
kind: "navigation",
|
|
851
|
+
label: presentation.navigationLabel ?? "Done",
|
|
852
|
+
enabled: presentation.allowEmpty || presentation.selectedOptions.length > 0,
|
|
853
|
+
},
|
|
854
|
+
]
|
|
855
|
+
: [],
|
|
850
856
|
},
|
|
851
857
|
{ redact: this.redact(), sessionTag: this.tag },
|
|
852
858
|
),
|
|
@@ -1824,9 +1830,12 @@ function registerInteractiveAnswerSource(
|
|
|
1824
1830
|
options: request.options,
|
|
1825
1831
|
controls: request.controls,
|
|
1826
1832
|
recommendedIndex: request.recommendedIndex,
|
|
1827
|
-
multi
|
|
1833
|
+
// The ask tool owns the multi-select loop and re-issues one request per
|
|
1834
|
+
// toggle; carrying its selection here is what makes the toggle visible
|
|
1835
|
+
// on a remote transport instead of an identical repeated prompt.
|
|
1836
|
+
multi: request.multi === true,
|
|
1828
1837
|
allowEmpty: false,
|
|
1829
|
-
selectedOptions: [],
|
|
1838
|
+
selectedOptions: [...(request.selectedOptions ?? [])],
|
|
1830
1839
|
onActivated: (actionId, lease) => {
|
|
1831
1840
|
if (pending.actionId && pendingInteractive.get(pending.actionId) === pending)
|
|
1832
1841
|
pendingInteractive.delete(pending.actionId);
|
|
@@ -75,9 +75,13 @@ export const NOTIFICATION_PROTOCOL_VERSION = 3;
|
|
|
75
75
|
* final-component file symlinks fail-closed under AT_SYMLINK_NOFOLLOW (bounded
|
|
76
76
|
* #3761 multi-account activation repair). Generation 51 adds shared durable
|
|
77
77
|
* topic authority, archive recovery, and requires Telegram's documented error
|
|
78
|
-
* code for idempotent archive settlement.
|
|
78
|
+
* code for idempotent archive settlement. Generation 52 is claimed by the
|
|
79
|
+
* pre-readiness daemon-child exit diagnostics slice (#3761). Generation 53
|
|
80
|
+
* renders multi-select state for ask-tool asks, not only durable workflow
|
|
81
|
+
* gates, and renumbers pre-numbered options exactly once around the selection
|
|
82
|
+
* marker.
|
|
79
83
|
*/
|
|
80
|
-
export const DAEMON_GENERATION =
|
|
84
|
+
export const DAEMON_GENERATION = 53;
|
|
81
85
|
|
|
82
86
|
/**
|
|
83
87
|
* Serving-compatibility boundary for daemon lifecycle requests. Epoch 5
|
|
@@ -48,6 +48,8 @@ import {
|
|
|
48
48
|
buildCompactChoiceGrid,
|
|
49
49
|
code,
|
|
50
50
|
markdownToTelegramHtml,
|
|
51
|
+
SELECTION_MARK_CHECKED,
|
|
52
|
+
SELECTION_MARK_UNCHECKED,
|
|
51
53
|
splitTelegramHtml,
|
|
52
54
|
TELEGRAM_MESSAGE_LIMIT,
|
|
53
55
|
TELEGRAM_PARSE_MODE,
|
|
@@ -383,6 +385,10 @@ const BTW_QUESTION_LIMIT_TEXT = "Question must be at most 4096 Unicode scalar va
|
|
|
383
385
|
type ParsedBtwCommand = { kind: "question"; question: string } | { kind: "ignored" };
|
|
384
386
|
type TelegramFileDownload = { bytes: Buffer } | { failure: "download_failed" | "too_large" };
|
|
385
387
|
class ThreadedModeCapabilityRefusal extends Error {}
|
|
388
|
+
/** Telegram answered `createForumTopic` with an explicit `ok: false` rejection. */
|
|
389
|
+
class TopicCreationRejected extends Error {}
|
|
390
|
+
/** The transport suppressed `createForumTopic` (no response body to classify). */
|
|
391
|
+
class TopicCreationSuppressed extends Error {}
|
|
386
392
|
|
|
387
393
|
async function prepareTelegramImageAttachment(frame: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
388
394
|
if (frame.type !== "image_attachment") return frame;
|
|
@@ -422,7 +428,7 @@ function isThreadedModeCapabilityRefusal(response: unknown): boolean {
|
|
|
422
428
|
return (
|
|
423
429
|
ok === false &&
|
|
424
430
|
typeof description === "string" &&
|
|
425
|
-
/(?:threaded mode|forum topics? (?:is|are) (?:disabled|not enabled)|(?:not allowed|not permitted|cannot|can't) create forum topics
|
|
431
|
+
/(?:threaded mode|forum topics? (?:is|are) (?:disabled|not enabled)|(?:not allowed|not permitted|cannot|can't) create forum topics?|chat is not a forum)/i.test(
|
|
426
432
|
description,
|
|
427
433
|
)
|
|
428
434
|
);
|
|
@@ -5068,6 +5074,8 @@ export class TelegramNotificationDaemon {
|
|
|
5068
5074
|
private readonly flatIdentitySent = new Set<string>();
|
|
5069
5075
|
/** Cached delivery boundary for the private owner chat or validation forum. */
|
|
5070
5076
|
private pairedChatPrivacy: PairedChatPrivacy | undefined;
|
|
5077
|
+
/** Latched once Telegram confirms this chat cannot host forum topics. */
|
|
5078
|
+
private topicCapabilityRefused = false;
|
|
5071
5079
|
/** Bot username from getMe, cached once at owner startup for group/forum command targeting. */
|
|
5072
5080
|
private botUsername: string | undefined;
|
|
5073
5081
|
/** Sessions whose agent loop is currently busy (drives the typing indicator). */
|
|
@@ -7836,8 +7844,6 @@ export class TelegramNotificationDaemon {
|
|
|
7836
7844
|
let acceptedTopicId: string | undefined;
|
|
7837
7845
|
let acceptedTopicCompensated = false;
|
|
7838
7846
|
let acceptedTopicArchiveAttempted = false;
|
|
7839
|
-
let creationSuppressed = false;
|
|
7840
|
-
let creationRejected = false;
|
|
7841
7847
|
let adoptedTopicId: number | undefined;
|
|
7842
7848
|
const adoptionIntentCandidate = this.#adoptionIntents.bySession(sessionId);
|
|
7843
7849
|
try {
|
|
@@ -7876,10 +7882,10 @@ export class TelegramNotificationDaemon {
|
|
|
7876
7882
|
const res = (await this.botApi.call("createForumTopic", { chat_id: this.opts.chatId, name })) as
|
|
7877
7883
|
| { result?: { message_thread_id?: unknown } }
|
|
7878
7884
|
| undefined;
|
|
7879
|
-
|
|
7880
|
-
|
|
7881
|
-
|
|
7882
|
-
|
|
7885
|
+
// Classification must travel with the rejection, not through
|
|
7886
|
+
// closure flags: `getOrCreateTopic` shares one in-flight create
|
|
7887
|
+
// promise, so every other awaiter observes only the error.
|
|
7888
|
+
if (res === undefined) throw new TopicCreationSuppressed();
|
|
7883
7889
|
if (isThreadedModeCapabilityRefusal(res)) throw new ThreadedModeCapabilityRefusal();
|
|
7884
7890
|
const response = res as {
|
|
7885
7891
|
ok?: unknown;
|
|
@@ -7887,8 +7893,8 @@ export class TelegramNotificationDaemon {
|
|
|
7887
7893
|
};
|
|
7888
7894
|
const tid = response.result?.message_thread_id;
|
|
7889
7895
|
if (typeof tid !== "number" || !Number.isSafeInteger(tid) || tid <= 0) {
|
|
7890
|
-
|
|
7891
|
-
|
|
7896
|
+
if (response.ok === false) throw new TopicCreationRejected();
|
|
7897
|
+
this.#malformedTopicCreateEndpoints.set(sessionId, creationEndpointKey);
|
|
7892
7898
|
throw new Error("createForumTopic: invalid message_thread_id");
|
|
7893
7899
|
}
|
|
7894
7900
|
acceptedTopicId = String(tid);
|
|
@@ -8009,7 +8015,12 @@ export class TelegramNotificationDaemon {
|
|
|
8009
8015
|
} catch (err) {
|
|
8010
8016
|
if (adoptedTopicId !== undefined) this.#adoptionIntents.releaseClaim(adoptedTopicId, sessionId);
|
|
8011
8017
|
if (adoptionIntentCandidate) this.topics.abandonCreateClaim(sessionId, creationLeaseEpoch);
|
|
8012
|
-
if (
|
|
8018
|
+
if (
|
|
8019
|
+
err instanceof TopicCreationSuppressed ||
|
|
8020
|
+
err instanceof TopicCreationRejected ||
|
|
8021
|
+
err instanceof ThreadedModeCapabilityRefusal
|
|
8022
|
+
) {
|
|
8023
|
+
if (err instanceof ThreadedModeCapabilityRefusal) this.topicCapabilityRefused = true;
|
|
8013
8024
|
if (this.topics.abandonCreateClaim(sessionId, creationLeaseEpoch)) await this.persistTopics();
|
|
8014
8025
|
return undefined;
|
|
8015
8026
|
}
|
|
@@ -9515,6 +9526,7 @@ export class TelegramNotificationDaemon {
|
|
|
9515
9526
|
}
|
|
9516
9527
|
|
|
9517
9528
|
private async pairedChatAllowsTopics(): Promise<boolean> {
|
|
9529
|
+
if (this.topicCapabilityRefused) return false;
|
|
9518
9530
|
const privacy = await this.resolvePairedChatPrivacy();
|
|
9519
9531
|
return privacy === "private" || privacy === "validation-forum";
|
|
9520
9532
|
}
|
|
@@ -9556,7 +9568,11 @@ export class TelegramNotificationDaemon {
|
|
|
9556
9568
|
private startFlushTimer(): void {
|
|
9557
9569
|
this.runtime.startInterval("telegram-flush", RATE_LIMIT_FLUSH_INTERVAL_MS, () => {
|
|
9558
9570
|
if (!this.running || this.pool.pending === 0) return;
|
|
9559
|
-
|
|
9571
|
+
// `flushPool` only swallows failures for the shared chain; the promise it
|
|
9572
|
+
// returns still rejects, and an unhandled rejection here exits the daemon.
|
|
9573
|
+
void this.flushPool().catch(error => {
|
|
9574
|
+
logger.warn(`notifications: queue flush failed: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`);
|
|
9575
|
+
});
|
|
9560
9576
|
});
|
|
9561
9577
|
}
|
|
9562
9578
|
|
|
@@ -9596,11 +9612,27 @@ export class TelegramNotificationDaemon {
|
|
|
9596
9612
|
this.runtime.stopInterval("telegram-owner-heartbeat");
|
|
9597
9613
|
}
|
|
9598
9614
|
|
|
9599
|
-
/**
|
|
9615
|
+
/**
|
|
9616
|
+
* Run a root scan, guarding against overlapping scans from the timer + loop.
|
|
9617
|
+
*
|
|
9618
|
+
* A reconciliation pass is retried every scan interval, so a failed one must
|
|
9619
|
+
* never tear the owner down. Both callers are fatal boundaries: the timer
|
|
9620
|
+
* fires and forgets, and the run loop awaits without a handler. A rejection
|
|
9621
|
+
* escaping either one (a shared topic authority whose lock or compare-and-set
|
|
9622
|
+
* is momentarily unavailable is the observed case) reaches the process-level
|
|
9623
|
+
* handler, which exits the daemon: every session topic is then left as an
|
|
9624
|
+
* unarchived shell that answers nothing, and the sessions that were still
|
|
9625
|
+
* live lose notifications too. Report the failure and let the next scan
|
|
9626
|
+
* retry instead.
|
|
9627
|
+
*/
|
|
9600
9628
|
private async runScan(): Promise<void> {
|
|
9601
|
-
|
|
9602
|
-
await this.
|
|
9603
|
-
|
|
9629
|
+
try {
|
|
9630
|
+
await this.runtime.runExclusive("telegram-scan", async () => {
|
|
9631
|
+
await this.scanRoots();
|
|
9632
|
+
});
|
|
9633
|
+
} catch (error) {
|
|
9634
|
+
logger.warn(`notifications: session scan failed: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`);
|
|
9635
|
+
}
|
|
9604
9636
|
}
|
|
9605
9637
|
|
|
9606
9638
|
private startScanTimer(): void {
|
|
@@ -10376,7 +10408,9 @@ export class TelegramNotificationDaemon {
|
|
|
10376
10408
|
: [],
|
|
10377
10409
|
);
|
|
10378
10410
|
const displayOptions = options.map((option, index) =>
|
|
10379
|
-
multiSelect
|
|
10411
|
+
multiSelect
|
|
10412
|
+
? `${selectedOptionIndices.has(index) ? SELECTION_MARK_CHECKED : SELECTION_MARK_UNCHECKED} ${option}`
|
|
10413
|
+
: option,
|
|
10380
10414
|
);
|
|
10381
10415
|
const rendered = buildActionMessage({
|
|
10382
10416
|
kind: msg.kind ?? "ask",
|