@sayknow-cli/coding-agent 0.3.8 → 0.3.9
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/dist/types/config/settings-schema.d.ts +14 -0
- package/dist/types/modes/components/thinking-selector.d.ts +2 -2
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
- package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
- package/dist/types/notifications/config-commands.d.ts +9 -0
- package/dist/types/notifications/config.d.ts +16 -0
- package/dist/types/notifications/index.d.ts +2 -0
- package/dist/types/notifications/lifecycle-commands.d.ts +1 -0
- package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
- package/dist/types/notifications/reply-sent-store.d.ts +53 -0
- package/dist/types/notifications/rich-draft.d.ts +68 -0
- package/dist/types/notifications/rich-render.d.ts +90 -0
- package/dist/types/notifications/telegram-daemon.d.ts +22 -0
- package/dist/types/notifications/telegram-reference.d.ts +7 -0
- package/dist/types/notifications/threaded-render.d.ts +8 -0
- package/dist/types/skc-runtime/tmux-common.d.ts +1 -0
- package/dist/types/utils/pasted-image-path.d.ts +30 -0
- package/package.json +7 -7
- package/src/cli/notify-cli.ts +11 -9
- package/src/cli/update-cli.ts +63 -24
- package/src/config/keybindings.ts +1 -1
- package/src/config/settings-schema.ts +8 -0
- package/src/internal-urls/docs-index.generated.ts +5 -5
- package/src/lsp/client.ts +1 -0
- package/src/modes/components/footer.ts +7 -2
- package/src/modes/components/thinking-selector.ts +10 -8
- package/src/modes/controllers/event-controller.ts +11 -2
- package/src/modes/controllers/extension-ui-controller.ts +8 -1
- package/src/modes/controllers/input-controller.ts +12 -17
- package/src/modes/controllers/selector-controller.ts +41 -0
- package/src/modes/interactive-mode.ts +5 -0
- package/src/modes/rpc/rpc-mode.ts +3 -9
- package/src/modes/rpc/rpc-socket-security.ts +13 -1
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/injected-user-submission.ts +94 -0
- package/src/notifications/config-commands.ts +20 -0
- package/src/notifications/config.ts +27 -0
- package/src/notifications/index.ts +28 -5
- package/src/notifications/lifecycle-commands.ts +37 -16
- package/src/notifications/lifecycle-control-runtime.ts +8 -3
- package/src/notifications/reply-sent-store.ts +134 -0
- package/src/notifications/rich-draft.ts +107 -0
- package/src/notifications/rich-render.ts +142 -0
- package/src/notifications/telegram-daemon.ts +346 -89
- package/src/notifications/telegram-reference.ts +17 -0
- package/src/notifications/threaded-render.ts +28 -1
- package/src/prompts/tools/search-tool-bm25.md +5 -0
- package/src/sdk.ts +36 -8
- package/src/session/agent-session.ts +6 -4
- package/src/skc-runtime/tmux-common.ts +1 -0
- package/src/skc-runtime/ultragoal-runtime.ts +10 -1
- package/src/slash-commands/builtin-registry.ts +78 -1
- package/src/utils/pasted-image-path.ts +170 -0
package/src/lsp/client.ts
CHANGED
|
@@ -835,6 +835,7 @@ async function shutdownClientInstance(client: LspClient): Promise<void> {
|
|
|
835
835
|
await Promise.race([shutdown, Bun.sleep(5_000)]);
|
|
836
836
|
await client.owner?.dispose();
|
|
837
837
|
await client.owner?.awaitExit({ timeoutMs: 1_000 });
|
|
838
|
+
await Promise.race([client.proc.exited.catch(() => undefined), Bun.sleep(1_000)]);
|
|
838
839
|
}
|
|
839
840
|
|
|
840
841
|
export async function shutdownClient(key: string): Promise<void> {
|
|
@@ -16,6 +16,7 @@ import { resolveCurrentBranch } from "./status-line/git-utils";
|
|
|
16
16
|
export class FooterComponent implements Component {
|
|
17
17
|
#cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name
|
|
18
18
|
#gitWatcher: fs.FSWatcher | null = null;
|
|
19
|
+
#gitWatcherGeneration = 0;
|
|
19
20
|
#onBranchChange: (() => void) | null = null;
|
|
20
21
|
#autoCompactEnabled: boolean = true;
|
|
21
22
|
#extensionStatuses: Map<string, string> = new Map();
|
|
@@ -51,7 +52,10 @@ export class FooterComponent implements Component {
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
#setupGitWatcher(): void {
|
|
54
|
-
// Clean up existing watcher
|
|
55
|
+
// Clean up existing watcher. The generation guard below keeps a stale
|
|
56
|
+
// in-flight resolve — one that dispose() or a newer setup already
|
|
57
|
+
// superseded — from installing a watcher nothing would ever close.
|
|
58
|
+
const generation = ++this.#gitWatcherGeneration;
|
|
55
59
|
if (this.#gitWatcher) {
|
|
56
60
|
this.#gitWatcher.close();
|
|
57
61
|
this.#gitWatcher = null;
|
|
@@ -60,7 +64,7 @@ export class FooterComponent implements Component {
|
|
|
60
64
|
void git.head
|
|
61
65
|
.resolve(getProjectDir())
|
|
62
66
|
.then(head => {
|
|
63
|
-
if (!head) {
|
|
67
|
+
if (!head || generation !== this.#gitWatcherGeneration) {
|
|
64
68
|
return;
|
|
65
69
|
}
|
|
66
70
|
|
|
@@ -84,6 +88,7 @@ export class FooterComponent implements Component {
|
|
|
84
88
|
* Clean up the file watcher
|
|
85
89
|
*/
|
|
86
90
|
dispose(): void {
|
|
91
|
+
this.#gitWatcherGeneration++;
|
|
87
92
|
if (this.#gitWatcher) {
|
|
88
93
|
this.#gitWatcher.close();
|
|
89
94
|
this.#gitWatcher = null;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { ThinkingLevel, type ThinkingLevel as ThinkingLevelValue } from "@sayknow-cli/agent-core";
|
|
2
2
|
import { Container, type SelectItem, SelectList } from "@sayknow-cli/tui";
|
|
3
3
|
import { getSelectListTheme } from "../../modes/theme/theme";
|
|
4
|
-
import { getThinkingLevelMetadata } from "../../thinking";
|
|
4
|
+
import { getThinkingLevelMetadata, type ThinkingLevelValue as ThinkingMetadataValue } from "../../thinking-metadata";
|
|
5
5
|
import { DynamicBorder } from "./dynamic-border";
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -11,14 +11,16 @@ export class ThinkingSelectorComponent extends Container {
|
|
|
11
11
|
#selectList: SelectList;
|
|
12
12
|
|
|
13
13
|
constructor(
|
|
14
|
-
currentLevel:
|
|
15
|
-
availableLevels:
|
|
16
|
-
onSelect: (level:
|
|
14
|
+
currentLevel: ThinkingLevelValue | undefined,
|
|
15
|
+
availableLevels: ThinkingLevelValue[],
|
|
16
|
+
onSelect: (level: ThinkingLevelValue) => void,
|
|
17
17
|
onCancel: () => void,
|
|
18
18
|
) {
|
|
19
19
|
super();
|
|
20
20
|
|
|
21
|
-
const thinkingLevels: SelectItem[] = availableLevels.map(
|
|
21
|
+
const thinkingLevels: SelectItem[] = availableLevels.map(level =>
|
|
22
|
+
getThinkingLevelMetadata(level as ThinkingMetadataValue),
|
|
23
|
+
);
|
|
22
24
|
|
|
23
25
|
// Add top border
|
|
24
26
|
this.addChild(new DynamicBorder());
|
|
@@ -27,13 +29,13 @@ export class ThinkingSelectorComponent extends Container {
|
|
|
27
29
|
this.#selectList = new SelectList(thinkingLevels, thinkingLevels.length, getSelectListTheme());
|
|
28
30
|
|
|
29
31
|
// Preselect current level
|
|
30
|
-
const currentIndex = thinkingLevels.findIndex(item => item.value === currentLevel);
|
|
32
|
+
const currentIndex = thinkingLevels.findIndex(item => item.value === (currentLevel ?? ThinkingLevel.Off));
|
|
31
33
|
if (currentIndex !== -1) {
|
|
32
34
|
this.#selectList.setSelectedIndex(currentIndex);
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
this.#selectList.onSelect = item => {
|
|
36
|
-
onSelect(item.value as
|
|
38
|
+
onSelect(item.value as ThinkingLevelValue);
|
|
37
39
|
};
|
|
38
40
|
|
|
39
41
|
this.#selectList.onCancel = () => {
|
|
@@ -16,6 +16,7 @@ import { ToolExecutionComponent } from "../../modes/components/tool-execution";
|
|
|
16
16
|
import { TtsrNotificationComponent } from "../../modes/components/ttsr-notification";
|
|
17
17
|
import { getSymbolTheme, theme } from "../../modes/theme/theme";
|
|
18
18
|
import type { InteractiveModeContext, TodoPhase } from "../../modes/types";
|
|
19
|
+
import { completionNotifyDisabledByEnv } from "../../notifications/config";
|
|
19
20
|
import { summaryFromMessage } from "../../notifications/helpers";
|
|
20
21
|
import type { PlanApprovalDetails } from "../../plan-mode/approved-plan";
|
|
21
22
|
import type { AgentSessionEvent } from "../../session/agent-session";
|
|
@@ -23,6 +24,7 @@ import { isSilentAbort, readPendingDisplayTag } from "../../session/messages";
|
|
|
23
24
|
import type { ResolveToolDetails } from "../../tools/resolve";
|
|
24
25
|
import { interruptHint } from "../shared";
|
|
25
26
|
import { buildAbortDisplayMessage } from "../utils/abort-message";
|
|
27
|
+
import { consumeInjectedOptimisticSignature } from "../utils/injected-user-submission";
|
|
26
28
|
import { ringTerminalBell } from "../utils/terminal-bell";
|
|
27
29
|
import { argsWithPartialJson } from "../utils/ui-helpers";
|
|
28
30
|
|
|
@@ -291,12 +293,17 @@ export class EventController {
|
|
|
291
293
|
const signature = `${textContent}\u0000${imageCount}`;
|
|
292
294
|
|
|
293
295
|
this.#resetReadGroup();
|
|
294
|
-
const
|
|
296
|
+
const wasSingleOptimistic = this.ctx.optimisticUserMessageSignature === signature;
|
|
297
|
+
const wasInjectedOptimistic = !wasSingleOptimistic && consumeInjectedOptimisticSignature(this.ctx, signature);
|
|
298
|
+
const wasOptimistic = wasSingleOptimistic || wasInjectedOptimistic;
|
|
295
299
|
const wasLocallySubmitted = this.ctx.locallySubmittedUserSignatures.delete(signature) || wasOptimistic;
|
|
296
300
|
if (!wasOptimistic) {
|
|
297
301
|
this.ctx.addMessageToChat(event.message);
|
|
298
302
|
}
|
|
299
|
-
|
|
303
|
+
// Only clear the single local slot when IT matched; an injected match is
|
|
304
|
+
// consumed from the counting Map above and must not clobber a coexisting
|
|
305
|
+
// pending local optimistic signature.
|
|
306
|
+
if (wasSingleOptimistic) {
|
|
300
307
|
this.ctx.optimisticUserMessageSignature = undefined;
|
|
301
308
|
}
|
|
302
309
|
|
|
@@ -960,6 +967,8 @@ export class EventController {
|
|
|
960
967
|
}
|
|
961
968
|
|
|
962
969
|
sendCompletionNotification(): void {
|
|
970
|
+
// Per-run hard opt-out (env, config-untouched, child-inheritable): SKC_NOTIFY=off.
|
|
971
|
+
if (completionNotifyDisabledByEnv(process.env)) return;
|
|
963
972
|
const isBackgrounded = this.ctx.isBackgrounded !== false;
|
|
964
973
|
const notify = settings.get("completion.notify");
|
|
965
974
|
if (notify === "off") return;
|
|
@@ -24,6 +24,7 @@ import { HookSelectorComponent } from "../../modes/components/hook-selector";
|
|
|
24
24
|
import { getAvailableThemesWithPaths, getThemeByName, setTheme, type Theme, theme } from "../../modes/theme/theme";
|
|
25
25
|
import type { InteractiveModeContext } from "../../modes/types";
|
|
26
26
|
import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator";
|
|
27
|
+
import { applyInjectedUserSubmission } from "../utils/injected-user-submission";
|
|
27
28
|
import { classifyHookSelectorBellEvent, ringTerminalBell } from "../utils/terminal-bell";
|
|
28
29
|
|
|
29
30
|
const MAX_WIDGET_LINES = 10;
|
|
@@ -954,7 +955,13 @@ export class ExtensionUiController {
|
|
|
954
955
|
}
|
|
955
956
|
|
|
956
957
|
#sendExtensionUserMessage: SendUserMessageHandler = (content, options) => {
|
|
957
|
-
|
|
958
|
+
// Compute queued BEFORE send: prompt() may flip session.isStreaming synchronously.
|
|
959
|
+
const queued = Boolean(options?.deliverAs) || this.ctx.session.isStreaming;
|
|
960
|
+
// Call send first so the busy/queued path finds the session queue populated
|
|
961
|
+
// (queueSteer/queueFollowUp push synchronously) before refreshing pending display.
|
|
962
|
+
const send = this.ctx.session.sendUserMessage(content, options);
|
|
963
|
+
applyInjectedUserSubmission(this.ctx, { content, queued });
|
|
964
|
+
send.catch((err: unknown) => {
|
|
958
965
|
this.ctx.showError(`Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
959
966
|
});
|
|
960
967
|
};
|
|
@@ -18,6 +18,7 @@ import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard";
|
|
|
18
18
|
import { getEditorCommand, openInEditor } from "../../utils/external-editor";
|
|
19
19
|
import { ensureSupportedImageInput, ImageInputTooLargeError, loadImageInput } from "../../utils/image-loading";
|
|
20
20
|
import { resizeImage } from "../../utils/image-resize";
|
|
21
|
+
import { resolvePastedImagePath } from "../../utils/pasted-image-path";
|
|
21
22
|
import { generateSessionTitle, setSessionTerminalTitle } from "../../utils/title-generator";
|
|
22
23
|
import { type QueuedMessageMoveDirection, QueuedMessageSelectorComponent } from "../components/queued-message-selector";
|
|
23
24
|
|
|
@@ -26,8 +27,6 @@ interface Expandable {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
const INTERACTIVE_ABORT_CLEANUP_TIMEOUT_MS = 5_000;
|
|
29
|
-
const CLIPBOARD_TEMP_IMAGE_FILE_PATTERN = /^clipboard-\d{4}-\d{2}-\d{2}-\d{6}-[A-Za-z0-9]+\.(?:png|jpe?g|gif|webp)$/i;
|
|
30
|
-
const MACOS_CLIPBOARD_TEMP_DIR_PATTERN = /^\/var\/folders\/[^/]+\/[^/]+\/T$/;
|
|
31
30
|
|
|
32
31
|
function isExpandable(obj: unknown): obj is Expandable {
|
|
33
32
|
return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
|
|
@@ -1061,10 +1060,15 @@ export class InputController {
|
|
|
1061
1060
|
}
|
|
1062
1061
|
|
|
1063
1062
|
handleTextPaste(text: string): boolean | Promise<boolean> {
|
|
1064
|
-
const imagePath = this
|
|
1063
|
+
const imagePath = resolvePastedImagePath(text, { cwd: this.ctx.sessionManager.getCwd() });
|
|
1065
1064
|
return imagePath ? this.#attachPastedImagePath(imagePath) : false;
|
|
1066
1065
|
}
|
|
1067
1066
|
|
|
1067
|
+
/**
|
|
1068
|
+
* Returns `false` on every failure path so the editor replays the original
|
|
1069
|
+
* bracketed paste — the raw path text must never be lost when attachment
|
|
1070
|
+
* is impossible (unsupported content, oversized image, load error).
|
|
1071
|
+
*/
|
|
1068
1072
|
async #attachPastedImagePath(imagePath: string): Promise<boolean> {
|
|
1069
1073
|
try {
|
|
1070
1074
|
const image = await loadImageInput({
|
|
@@ -1073,8 +1077,8 @@ export class InputController {
|
|
|
1073
1077
|
autoResize: this.ctx.settings.get("images.autoResize"),
|
|
1074
1078
|
});
|
|
1075
1079
|
if (!image) {
|
|
1076
|
-
this.ctx.showStatus("Unsupported pasted
|
|
1077
|
-
return
|
|
1080
|
+
this.ctx.showStatus("Unsupported pasted image file");
|
|
1081
|
+
return false;
|
|
1078
1082
|
}
|
|
1079
1083
|
|
|
1080
1084
|
this.ctx.pendingImages.push({
|
|
@@ -1089,22 +1093,13 @@ export class InputController {
|
|
|
1089
1093
|
} catch (error) {
|
|
1090
1094
|
if (error instanceof ImageInputTooLargeError) {
|
|
1091
1095
|
this.ctx.showStatus(error.message);
|
|
1092
|
-
return
|
|
1096
|
+
return false;
|
|
1093
1097
|
}
|
|
1094
|
-
this.ctx.showStatus("Failed to attach pasted
|
|
1095
|
-
return
|
|
1098
|
+
this.ctx.showStatus("Failed to attach pasted image");
|
|
1099
|
+
return false;
|
|
1096
1100
|
}
|
|
1097
1101
|
}
|
|
1098
1102
|
|
|
1099
|
-
#getPastedImagePathCandidate(text: string): string | undefined {
|
|
1100
|
-
const resolvedPath = path.resolve(text.trim());
|
|
1101
|
-
const parentDir = path.dirname(resolvedPath);
|
|
1102
|
-
const isClipboardTempPath =
|
|
1103
|
-
(parentDir === "/tmp" || MACOS_CLIPBOARD_TEMP_DIR_PATTERN.test(parentDir)) &&
|
|
1104
|
-
CLIPBOARD_TEMP_IMAGE_FILE_PATTERN.test(path.basename(resolvedPath));
|
|
1105
|
-
return isClipboardTempPath ? resolvedPath : undefined;
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
1103
|
#nextImagePlaceholder(): string {
|
|
1109
1104
|
return `[image ${this.ctx.pendingImages.length}]`;
|
|
1110
1105
|
}
|
|
@@ -81,6 +81,7 @@ import { SessionSelectorComponent } from "../components/session-selector";
|
|
|
81
81
|
import { SettingsSelectorComponent } from "../components/settings-selector";
|
|
82
82
|
import type { StatusLineSettings } from "../components/status-line";
|
|
83
83
|
import { ThemeSelectorComponent } from "../components/theme-selector";
|
|
84
|
+
import { ThinkingSelectorComponent } from "../components/thinking-selector";
|
|
84
85
|
import { ToolExecutionComponent } from "../components/tool-execution";
|
|
85
86
|
import { TreeSelectorComponent } from "../components/tree-selector";
|
|
86
87
|
import { UserMessageSelectorComponent } from "../components/user-message-selector";
|
|
@@ -328,6 +329,46 @@ export class SelectorController {
|
|
|
328
329
|
});
|
|
329
330
|
}
|
|
330
331
|
|
|
332
|
+
showEffortSelector(): void {
|
|
333
|
+
const availableLevels = [
|
|
334
|
+
ThinkingLevel.Inherit,
|
|
335
|
+
ThinkingLevel.Off,
|
|
336
|
+
...this.ctx.session.getAvailableThinkingLevels(),
|
|
337
|
+
];
|
|
338
|
+
|
|
339
|
+
this.showSelector(done => {
|
|
340
|
+
const selector = new ThinkingSelectorComponent(
|
|
341
|
+
this.ctx.session.thinkingLevel,
|
|
342
|
+
availableLevels,
|
|
343
|
+
level => {
|
|
344
|
+
done();
|
|
345
|
+
|
|
346
|
+
const configuredDefault = this.ctx.settings.get("defaultThinkingLevel");
|
|
347
|
+
const levelToApply = level === ThinkingLevel.Inherit ? configuredDefault : level;
|
|
348
|
+
this.ctx.session.setThinkingLevel(levelToApply, false);
|
|
349
|
+
const effectiveLevel = this.ctx.session.thinkingLevel ?? ThinkingLevel.Off;
|
|
350
|
+
const requestedLabel =
|
|
351
|
+
level === ThinkingLevel.Inherit ? `${level} (configured default: ${configuredDefault})` : level;
|
|
352
|
+
const clampedSuffix =
|
|
353
|
+
effectiveLevel === levelToApply ? "" : ` Requested ${levelToApply}; effective ${effectiveLevel}.`;
|
|
354
|
+
|
|
355
|
+
this.ctx.statusLine.invalidate();
|
|
356
|
+
this.ctx.updateEditorBorderColor();
|
|
357
|
+
this.ctx.updateEditorTopBorder();
|
|
358
|
+
this.ctx.ui.requestRender();
|
|
359
|
+
this.ctx.showStatus(
|
|
360
|
+
`Reasoning effort set to ${requestedLabel}. Effective effort: ${effectiveLevel}.${clampedSuffix}`,
|
|
361
|
+
);
|
|
362
|
+
},
|
|
363
|
+
() => {
|
|
364
|
+
done();
|
|
365
|
+
this.ctx.ui.requestRender();
|
|
366
|
+
},
|
|
367
|
+
);
|
|
368
|
+
return { component: selector, focus: selector.getSelectList() };
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
331
372
|
showSettingsSelector(): void {
|
|
332
373
|
getAvailableThemes().then(availableThemes => {
|
|
333
374
|
this.showSelector(done => {
|
|
@@ -331,6 +331,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
331
331
|
onInputCallback?: (input: SubmittedUserInput) => void;
|
|
332
332
|
optimisticUserMessageSignature: string | undefined = undefined;
|
|
333
333
|
locallySubmittedUserSignatures: Set<string> = new Set();
|
|
334
|
+
optimisticInjectedSignatures: Map<string, number> = new Map();
|
|
334
335
|
#pendingSubmittedInput: SubmittedUserInput | undefined;
|
|
335
336
|
#pendingSubmissionDispose: (() => void) | undefined;
|
|
336
337
|
lastSigintTime = 0;
|
|
@@ -2561,6 +2562,10 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
2561
2562
|
this.#selectorController.showModelSelector(options);
|
|
2562
2563
|
}
|
|
2563
2564
|
|
|
2565
|
+
showEffortSelector(): void {
|
|
2566
|
+
this.#selectorController.showEffortSelector();
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2564
2569
|
showProviderOnboarding(): void {
|
|
2565
2570
|
this.#selectorController.showProviderOnboarding();
|
|
2566
2571
|
}
|
|
@@ -173,15 +173,9 @@ function auditOutcomeFor(event: string): "accepted" | "rejected" | "denied" | "e
|
|
|
173
173
|
return "info";
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
`RPC --listen refused: a live server is already listening on ${socketPath}. ` +
|
|
180
|
-
"Stop it first or choose a different --listen path.",
|
|
181
|
-
);
|
|
182
|
-
this.name = "RpcListenRefusedError";
|
|
183
|
-
}
|
|
184
|
-
}
|
|
176
|
+
// Thrown by prepareRpcSocketPath when the --listen path has a live owner;
|
|
177
|
+
// re-exported here because main.ts catches it at the RPC launch boundary.
|
|
178
|
+
export { RpcListenRefusedError } from "./rpc-socket-security";
|
|
185
179
|
|
|
186
180
|
/**
|
|
187
181
|
* Probe whether a unix-domain socket path has a live server accepting
|
|
@@ -9,6 +9,18 @@ export class RpcSocketSecurityError extends Error {
|
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
// The launch boundary in main.ts catches exactly this class to turn a
|
|
13
|
+
// duplicate --listen into a clean stderr diagnostic + non-zero exit (issue 19).
|
|
14
|
+
export class RpcListenRefusedError extends Error {
|
|
15
|
+
constructor(socketPath: string) {
|
|
16
|
+
super(
|
|
17
|
+
`RPC --listen refused: a live server is already listening on ${socketPath}. ` +
|
|
18
|
+
"Stop it first or choose a different --listen path.",
|
|
19
|
+
);
|
|
20
|
+
this.name = "RpcListenRefusedError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
12
24
|
const unsafeBits = 0o077;
|
|
13
25
|
|
|
14
26
|
function currentUid(): number | undefined {
|
|
@@ -53,7 +65,7 @@ export async function prepareRpcSocketPath(socketPath: string): Promise<void> {
|
|
|
53
65
|
if (!existing.isSocket()) throw new RpcSocketSecurityError(`RPC socket path is not a socket: ${socketPath}`);
|
|
54
66
|
assertPrivateMode(existing.mode, socketPath);
|
|
55
67
|
if (await probeUnixSocketAlive(socketPath)) {
|
|
56
|
-
throw new
|
|
68
|
+
throw new RpcListenRefusedError(socketPath);
|
|
57
69
|
}
|
|
58
70
|
await fs.unlink(socketPath);
|
|
59
71
|
}
|
package/src/modes/types.ts
CHANGED
|
@@ -116,6 +116,7 @@ export interface InteractiveModeContext {
|
|
|
116
116
|
onInputCallback?: (input: SubmittedUserInput) => void;
|
|
117
117
|
optimisticUserMessageSignature: string | undefined;
|
|
118
118
|
locallySubmittedUserSignatures: Set<string>;
|
|
119
|
+
optimisticInjectedSignatures: Map<string, number>;
|
|
119
120
|
lastSigintTime: number;
|
|
120
121
|
lastEscapeTime: number;
|
|
121
122
|
lastComposerClearEscapeTime: number;
|
|
@@ -244,6 +245,7 @@ export interface InteractiveModeContext {
|
|
|
244
245
|
showExtensionsDashboard(): void;
|
|
245
246
|
showAgentsDashboard(): void;
|
|
246
247
|
showModelSelector(options?: { temporaryOnly?: boolean }): void;
|
|
248
|
+
showEffortSelector(): void;
|
|
247
249
|
showProviderOnboarding(): void;
|
|
248
250
|
showPluginSelector(mode?: "install" | "uninstall"): void;
|
|
249
251
|
showUserMessageSelector(): void;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { ImageContent, TextContent } from "@sayknow-cli/ai";
|
|
2
|
+
import type { InteractiveModeContext } from "../types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Normalize the content passed to an extension `sendUserMessage` call into a
|
|
6
|
+
* plain text string plus its image attachments. Mirrors the normalization in
|
|
7
|
+
* `AgentSession.sendUserMessage` (text parts joined with "\n") so the resulting
|
|
8
|
+
* text matches the eventual user `message_start` payload.
|
|
9
|
+
*/
|
|
10
|
+
export function normalizeInjectedUserContent(content: string | (TextContent | ImageContent)[]): {
|
|
11
|
+
text: string;
|
|
12
|
+
images: ImageContent[];
|
|
13
|
+
imageCount: number;
|
|
14
|
+
} {
|
|
15
|
+
if (typeof content === "string") {
|
|
16
|
+
return { text: content, images: [], imageCount: 0 };
|
|
17
|
+
}
|
|
18
|
+
const textParts: string[] = [];
|
|
19
|
+
const images: ImageContent[] = [];
|
|
20
|
+
for (const part of content) {
|
|
21
|
+
if (part.type === "text") textParts.push(part.text);
|
|
22
|
+
else images.push(part);
|
|
23
|
+
}
|
|
24
|
+
const text = textParts.join("\n");
|
|
25
|
+
return { text, images, imageCount: images.length };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Record a remotely/programmatically injected user message (e.g. Telegram
|
|
30
|
+
* inbound routed through the extension API) into the interactive TUI, so it is
|
|
31
|
+
* captured in prompt history and shown immediately instead of only appearing
|
|
32
|
+
* once the eventual `message_start` event lands.
|
|
33
|
+
*
|
|
34
|
+
* Local TUI submissions never reach this path (they go through
|
|
35
|
+
* `session.prompt(...)` / `startPendingSubmission`), so this cannot double-add
|
|
36
|
+
* local prompt history.
|
|
37
|
+
*
|
|
38
|
+
* - Always adds the injected text to editor prompt history.
|
|
39
|
+
* - Idle injections optimistically render the user message and record a pending
|
|
40
|
+
* injected optimistic signature (a counting Map, so multiple idle injections
|
|
41
|
+
* before the first `message_start` do not clobber each other); the later user
|
|
42
|
+
* `message_start` consumes one count and skips both the duplicate chat add and
|
|
43
|
+
* the defensive editor clear (so a locally typed draft is preserved).
|
|
44
|
+
* - Busy/queued injections refresh the pending-message display, which the
|
|
45
|
+
* caller has already populated by invoking `session.sendUserMessage(...)`
|
|
46
|
+
* before this helper.
|
|
47
|
+
*
|
|
48
|
+
* This helper never clears the editor text.
|
|
49
|
+
*/
|
|
50
|
+
export function applyInjectedUserSubmission(
|
|
51
|
+
ctx: InteractiveModeContext,
|
|
52
|
+
input: { content: string | (TextContent | ImageContent)[]; queued: boolean },
|
|
53
|
+
): void {
|
|
54
|
+
const { text, images, imageCount } = normalizeInjectedUserContent(input.content);
|
|
55
|
+
ctx.editor.addToHistory(text);
|
|
56
|
+
|
|
57
|
+
if (input.queued) {
|
|
58
|
+
ctx.updatePendingMessagesDisplay();
|
|
59
|
+
ctx.ui.requestRender();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
incrementInjectedOptimisticSignature(ctx, `${text}\u0000${imageCount}`);
|
|
64
|
+
ctx.addMessageToChat({
|
|
65
|
+
role: "user",
|
|
66
|
+
content: [{ type: "text", text }, ...images],
|
|
67
|
+
attribution: "user",
|
|
68
|
+
timestamp: Date.now(),
|
|
69
|
+
});
|
|
70
|
+
ctx.ui.requestRender();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Record one pending optimistic render for an injected user message.
|
|
75
|
+
*
|
|
76
|
+
* Injected sends are fire-and-forget and multiple idle injections can be
|
|
77
|
+
* rendered before the first `message_start` arrives, so a counting Map (not a
|
|
78
|
+
* single slot) tracks how many optimistic renders are outstanding per signature.
|
|
79
|
+
*/
|
|
80
|
+
export function incrementInjectedOptimisticSignature(ctx: InteractiveModeContext, signature: string): void {
|
|
81
|
+
ctx.optimisticInjectedSignatures.set(signature, (ctx.optimisticInjectedSignatures.get(signature) ?? 0) + 1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Consume one pending injected optimistic render for `signature`. Decrements the
|
|
86
|
+
* count (deleting the key at zero) and returns whether one was outstanding.
|
|
87
|
+
*/
|
|
88
|
+
export function consumeInjectedOptimisticSignature(ctx: InteractiveModeContext, signature: string): boolean {
|
|
89
|
+
const count = ctx.optimisticInjectedSignatures.get(signature) ?? 0;
|
|
90
|
+
if (count <= 0) return false;
|
|
91
|
+
if (count === 1) ctx.optimisticInjectedSignatures.delete(signature);
|
|
92
|
+
else ctx.optimisticInjectedSignatures.set(signature, count - 1);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
@@ -48,3 +48,23 @@ export function parseInThreadConfigCommand(text: string): ConfigCommandChange |
|
|
|
48
48
|
return undefined;
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Parse a `/rich on|off` toggle. Returns `true`/`false` for a recognised
|
|
54
|
+
* on/off argument, or `undefined` otherwise (not a `/rich` command, or `/rich`
|
|
55
|
+
* with a missing/invalid argument). This is intentionally SEPARATE from
|
|
56
|
+
* `parseInThreadConfigCommand`: `/verbose`/`/redact` are producer/session config
|
|
57
|
+
* forwarded over the WS, whereas rich is Telegram-daemon delivery policy handled
|
|
58
|
+
* daemon-locally, so it never becomes a `config_command` frame or a user turn.
|
|
59
|
+
*/
|
|
60
|
+
export function parseRichToggleCommand(text: string): boolean | undefined {
|
|
61
|
+
const trimmed = text.trim();
|
|
62
|
+
if (!trimmed.startsWith("/")) return undefined;
|
|
63
|
+
const [rawCommand, ...rest] = trimmed.slice(1).split(/\s+/);
|
|
64
|
+
// Accept the "/rich@botname" form Telegram appends in group chats.
|
|
65
|
+
if (rawCommand?.toLowerCase().split("@")[0] !== "rich") return undefined;
|
|
66
|
+
const arg = rest[0]?.toLowerCase();
|
|
67
|
+
if (arg === "on" || arg === "true" || arg === "1") return true;
|
|
68
|
+
if (arg === "off" || arg === "false" || arg === "0") return false;
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
@@ -16,6 +16,12 @@ export interface NotificationConfig {
|
|
|
16
16
|
redact: boolean;
|
|
17
17
|
verbosity: "lean" | "verbose";
|
|
18
18
|
idleTimeoutMs: number;
|
|
19
|
+
rich: {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
};
|
|
22
|
+
richDraft: {
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
};
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
/** Read typed config from Settings. */
|
|
@@ -35,6 +41,12 @@ export function getNotificationConfig(settings: Settings): NotificationConfig {
|
|
|
35
41
|
redact: settings.get("notifications.redact"),
|
|
36
42
|
verbosity: settings.get("notifications.verbosity") === "verbose" ? "verbose" : "lean",
|
|
37
43
|
idleTimeoutMs: settings.get("notifications.daemon.idleTimeoutMs"),
|
|
44
|
+
rich: {
|
|
45
|
+
enabled: settings.get("notifications.telegram.rich.enabled"),
|
|
46
|
+
},
|
|
47
|
+
richDraft: {
|
|
48
|
+
enabled: settings.get("notifications.telegram.richDraft.enabled"),
|
|
49
|
+
},
|
|
38
50
|
};
|
|
39
51
|
}
|
|
40
52
|
|
|
@@ -59,6 +71,20 @@ export function isGloballyConfigured(cfg: NotificationConfig): boolean {
|
|
|
59
71
|
);
|
|
60
72
|
}
|
|
61
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Per-run opt-out for completion notifications, honored before settings lookups.
|
|
76
|
+
*
|
|
77
|
+
* `SKC_NOTIFY=off` (also `0` / `false`, case-insensitive) suppresses the
|
|
78
|
+
* completion notification surface for this process only. `config.yml` is
|
|
79
|
+
* untouched and child processes inherit the env var, which lets non-interactive
|
|
80
|
+
* fleet runs (`skc -p --no-session`) stay silent even when a user-level/global
|
|
81
|
+
* completion notification configuration is enabled.
|
|
82
|
+
*/
|
|
83
|
+
export function completionNotifyDisabledByEnv(env: NodeJS.ProcessEnv): boolean {
|
|
84
|
+
const v = env.SKC_NOTIFY?.trim().toLowerCase();
|
|
85
|
+
return v === "off" || v === "0" || v === "false";
|
|
86
|
+
}
|
|
87
|
+
|
|
62
88
|
/** Resolve whether the notifications extension should be registered at SDK startup. */
|
|
63
89
|
export function shouldRegisterNotificationsExtension(input: {
|
|
64
90
|
env: NodeJS.ProcessEnv;
|
|
@@ -71,6 +97,7 @@ export function shouldRegisterNotificationsExtension(input: {
|
|
|
71
97
|
currentAgentType?: string;
|
|
72
98
|
}): boolean {
|
|
73
99
|
if ((input.taskDepth ?? 0) > 0 || input.parentTaskPrefix || input.currentAgentType) return false;
|
|
100
|
+
if (completionNotifyDisabledByEnv(input.env)) return false;
|
|
74
101
|
if (input.env.SKC_NOTIFICATIONS === "0") return false;
|
|
75
102
|
if (input.env.SKC_NOTIFICATIONS === "1" || input.env.SKC_NOTIFICATIONS_TOKEN) return true;
|
|
76
103
|
return input.cfg ? isGloballyConfigured(input.cfg) : false;
|
|
@@ -90,6 +90,8 @@ export interface SessionCreateFrame {
|
|
|
90
90
|
target: SessionCreateTarget;
|
|
91
91
|
/** Reference to the daemon-written, once-consumed startup-prompt file. */
|
|
92
92
|
startupPromptRef?: string;
|
|
93
|
+
/** Model profile preset to activate for the spawned session (--mpreset). */
|
|
94
|
+
modelPreset?: string;
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
/** Close (hard-kill, history preserved) a session. */
|
|
@@ -356,6 +358,9 @@ interface SessionRuntime {
|
|
|
356
358
|
liveRef?: string;
|
|
357
359
|
lastLiveAt?: number;
|
|
358
360
|
lastLiveText?: string;
|
|
361
|
+
/** True between turn_end and the next turn_start: drops late async message_update
|
|
362
|
+
* frames so a stale live edit can never be emitted after the finalized turn. */
|
|
363
|
+
turnClosed?: boolean;
|
|
359
364
|
/** Cancels the postmortem cleanup that emits `session_closed` on process teardown. */
|
|
360
365
|
cancelPostmortemCleanup: () => void;
|
|
361
366
|
}
|
|
@@ -383,6 +388,8 @@ const defaultConfig: NotificationConfig = {
|
|
|
383
388
|
redact: false,
|
|
384
389
|
verbosity: "lean",
|
|
385
390
|
idleTimeoutMs: 60_000,
|
|
391
|
+
rich: { enabled: true },
|
|
392
|
+
richDraft: { enabled: false },
|
|
386
393
|
};
|
|
387
394
|
|
|
388
395
|
export function notificationsEnabled(): boolean {
|
|
@@ -997,7 +1004,10 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
|
|
|
997
1004
|
api.on("turn_start", (_event, ctx) => {
|
|
998
1005
|
const id = sessionId(ctx);
|
|
999
1006
|
const rt = runtimes.get(id);
|
|
1000
|
-
if (!rt
|
|
1007
|
+
if (!rt) return;
|
|
1008
|
+
// A new turn is live: re-open the live-stream window (see turnClosed).
|
|
1009
|
+
rt.turnClosed = false;
|
|
1010
|
+
if (rt.pendingInbound.size === 0) return;
|
|
1001
1011
|
for (const updateId of rt.pendingInbound) {
|
|
1002
1012
|
try {
|
|
1003
1013
|
rt.server.pushFrame(JSON.stringify({ type: "inbound_ack", sessionId: id, updateId, state: "consumed" }));
|
|
@@ -1093,15 +1103,25 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
|
|
|
1093
1103
|
// rate-limit pool before sending to Telegram.
|
|
1094
1104
|
// Push the in-flight turn's assistant text as a finalized turn_stream, deduped
|
|
1095
1105
|
// against what was already flushed for this turn (the pre-ask lead-in).
|
|
1096
|
-
const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined): void => {
|
|
1106
|
+
const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined, finalAnswer: boolean): void => {
|
|
1097
1107
|
if (!text || text === rt.preAskFlushedText) return;
|
|
1098
1108
|
rt.preAskFlushedText = text;
|
|
1109
|
+
// Decision A: a stream-enabled turn must finalize as an in-place edit of ONE
|
|
1110
|
+
// live message, never a fresh (rich-promotable) send. If live frames were
|
|
1111
|
+
// async-queued and none landed before this flush, allocate the per-turn ref
|
|
1112
|
+
// now so the finalized frame always carries a messageRef → the daemon keeps it
|
|
1113
|
+
// editable (HTML edit) and never rich-promotes a streamed final.
|
|
1114
|
+
if (finalAnswer && rt.stream && rt.liveRef === undefined) {
|
|
1115
|
+
rt.turnSeq = (rt.turnSeq ?? 0) + 1;
|
|
1116
|
+
rt.liveRef = String(rt.turnSeq);
|
|
1117
|
+
}
|
|
1099
1118
|
try {
|
|
1100
1119
|
rt.server.pushFrame(
|
|
1101
1120
|
JSON.stringify({
|
|
1102
1121
|
type: "turn_stream",
|
|
1103
1122
|
sessionId: id,
|
|
1104
1123
|
phase: "finalized",
|
|
1124
|
+
finalAnswer,
|
|
1105
1125
|
text,
|
|
1106
1126
|
...(rt.liveRef ? { messageRef: rt.liveRef } : {}),
|
|
1107
1127
|
}),
|
|
@@ -1122,7 +1142,7 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
|
|
|
1122
1142
|
const id = sessionId(ctx);
|
|
1123
1143
|
const rt = runtimes.get(id);
|
|
1124
1144
|
if (!rt || rt.redact) return;
|
|
1125
|
-
flushTurnText(rt, id, rt.currentTurnText);
|
|
1145
|
+
flushTurnText(rt, id, rt.currentTurnText, false);
|
|
1126
1146
|
});
|
|
1127
1147
|
|
|
1128
1148
|
api.on("turn_end", (event, ctx) => {
|
|
@@ -1130,12 +1150,15 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
|
|
|
1130
1150
|
const rt = runtimes.get(id);
|
|
1131
1151
|
if (!rt) return;
|
|
1132
1152
|
const text = rt.redact ? undefined : summaryFromMessage(event.message, turnTextMax());
|
|
1133
|
-
if (text) flushTurnText(rt, id, text);
|
|
1153
|
+
if (text) flushTurnText(rt, id, text, true);
|
|
1134
1154
|
// Reset per-turn streaming state so the next turn starts fresh and a later
|
|
1135
1155
|
// turn with identical text is not falsely deduped.
|
|
1136
1156
|
rt.currentTurnText = undefined;
|
|
1137
1157
|
rt.preAskFlushedText = undefined;
|
|
1138
1158
|
rt.liveRef = undefined;
|
|
1159
|
+
// Close the live-stream window: any message_update queued after turn_end is
|
|
1160
|
+
// dropped so it can never emit a stale live edit past the finalized turn.
|
|
1161
|
+
rt.turnClosed = true;
|
|
1139
1162
|
rt.lastLiveAt = undefined;
|
|
1140
1163
|
rt.lastLiveText = undefined;
|
|
1141
1164
|
});
|
|
@@ -1147,7 +1170,7 @@ export function createNotificationsExtension(api: ExtensionAPI, options: { setti
|
|
|
1147
1170
|
api.on("message_update", (event, ctx) => {
|
|
1148
1171
|
const id = sessionId(ctx);
|
|
1149
1172
|
const rt = runtimes.get(id);
|
|
1150
|
-
if (!rt?.stream || rt.redact) return;
|
|
1173
|
+
if (!rt?.stream || rt.redact || rt.turnClosed) return;
|
|
1151
1174
|
if ((event.message as { role?: unknown }).role !== "assistant") return;
|
|
1152
1175
|
if (rt.liveRef === undefined) {
|
|
1153
1176
|
rt.turnSeq = (rt.turnSeq ?? 0) + 1;
|