Repository navigation
v0.4.0-preview0011
Pre-release[v0.4.0-preview0011]
Security
-
A disabled tool can no longer be executed.
-DisableTerminal,
-DisableFileAccess,-DisableBrowsing,-DisableUserPromptsand
-DisableTodoListremoved a tool from the set offered to the model, but the
dispatch switch matched built-in tool names unconditionally — so a model that
named a disabled tool anyway, from its own priors or from a replayed history,
had it run.-DisableTerminalbounded what was advertised and nothing about
what executed.Dispatch now refuses any built-in that this call did not offer, before the
tool runs. The refusal reuses the existing tool-policy path: thetool.call
event carriespolicy = denied, the reason names the disabled tool, the call
appears onToolCallsDenied, and the model receives{"denied": "..."}so it
can choose another route instead of failing the turn. The offered set is
derived from the assembled tool list rather than re-tested against each
switch, so a tool added later cannot be offered under one condition and
dispatched under another. -
Register-ShpToolrefuses a built-in tool name. Dispatch matches built-in
names before it consults the user tool table, so registeringrun_command,
read_fileor any other built-in produced a tool that was advertised to the
model and then silently ignored while the built-in ran instead — with the
caller believing it had replaced it. An attached MCP server has always been
refused a colliding name; a local registration now fails the same way, loudly
and at registration time. Choose a distinct-ToolName.
Added
-
glob_filesandgrep_fileslet the model search without a shell.
glob_filesfinds files by name pattern under a directory;grep_files
searches their contents and returns only the path, line number and matching
line, leavingread_fileto read around a hit. Both are offered with the
other file tools and withdrawn by-DisableFileAccess.Both are governed by the existing
Read()rules ofSet-ShpToolPolicy, and
that is the point: until now the only way to make the model find something
wasrun_command, so a policy tight enough to be worth setting had to grant
Shell(...)— far more reach than searching needs.Set-ShpToolPolicy -Rule 'Read(./**)'is now enough to let the model locate a file by name or by
content whilerun_commandstays denied.Every returned hit is policy-checked, not just the search root, because a
glob rooted at an allowed directory can still match a path that resolves,
through a link, to somewhere no rule covers. An excluded hit is counted in
excludedByPolicyrather than dropped silently. Both results are bounded —
files examined, matches returned, and characters returned — and set
truncatedwhen any cap bites, so the model narrows the pattern instead of
overflowing the context window. -
ConvertTo-ShpAnnotationsurfaces structured findings in CI. Pipe a
ShellPilot.ResultfromInvoke-Shp -JsonSchema, or any plain finding
object, into the cmdlet to produce GitHub Actions annotations, Azure DevOps
task.logissuecommands, or readable text.Level,Path,Line,
Column,Title, andMessageare matched case-insensitively and can be
redirected with-PropertyMap; an unknown or missing level is always a
warning. Vendor-specific escaping keeps newlines and delimiters from
corrupting a workflow command. Output stays on the success stream unless
-Emitwrites it to the host, and-Summaryappends a Markdown table to
$env:GITHUB_STEP_SUMMARYwhen available.
See specs/028-ci-annotations.md. -
Invoke-Shp -EventStream <path>writes a headless JSONL event stream. A
CI log collector reads lines, not prose: everything the module said about a
running turn was aimed at a person, so a nineteen-iteration turn that was
refused twice by the tool policy, retried once on an expired session token
and then stopped on-MaxBudgetUSDleft one object saying
BudgetExceeded = $trueand nothing about the shape of the failure. The
stream appends one JSON object per line -turn.start,model.request,
usage,reasoning(one per streamed chunk under-ShowThinking),
tool.call,tool.result,todo,retry,error,final- each carrying
schemaVersion, a monotonicsequence, an ISO 8601 UTCtimestamp, a
typeand a flatdataobject. Pass-to write the records to the
Information stream instead of a file. Every line is appended whole, so a run
killed mid-turn still leaves a file that parses up to its last complete line;
a later call appending to that valid stream continues the sequence. Every
string payload goes through the same redaction seam the request body does, so
a secret a tool printed does not reach the stream verbatim; arun_command
tool-call record names the tool and the policy decision but never the command
line. The complete streamed reasoning trace is redacted before it is divided
back into Event records, so an SSE boundary cannot split a secret around the
redaction seam; partial reasoning is retained before aretryorerror.
Transient HTTP and network-outage retries from the shared request wrapper are
recorded with attempt, delay and status data, and an inventedask_usercall
in a non-interactive turn records its denied Tool call and terminal error
before the call stops.-DisableProgressEventsno longer switches this off -
the two sinks are gated independently.
See specs/027-headless-event-stream.md. -
Invoke-Shp -AsJobandInvoke-ShpBatch -AsJobrun a call in the
background. Both return a thread job whoseReceive-Jobresolves to the
sameShellPilot.Result/ShellPilot.BatchResultobjects the synchronous
call returns - the same process, so nothing is serialised into a
Deserialized.*copy. The job runspace inherits no module state, so the
session context, session defaults, cached model limits, tool policy,
redaction policy and registered tools are replayed into it and the module is
imported by path.Invoke-Shp -AsJobis seeded from a snapshot of the
session conversation and stays stateless from there, because a job that
finishes at an arbitrary time must not race the caller's next call. The CI
entitlement gate is still evaluated at the call site, so a refused backend
fails where you typed it rather than in the background of a green build. An
event stream is honoured:-AsJobdoes not silently turn it off.
See specs/027-headless-event-stream.md. -
Invoke-Shpnow redacts secrets before they leave the runner. A CI job
feeds the model diffs, build logs and attachments produced by untrusted
pull-request content, and nothing scrubbed them before now - a leaked token
in a log became a token sent to a third party. Immediately before each
round-trip, the prompt, every inlined-Attachment, and every tool result
(run_command,read_file,fetch_url, an MCP tool, a user-defined tool)
is scanned for six built-in shapes - GitHub tokens, AWS access key ids, PEM
private-key blocks, JWTs, basic-auth URL credentials, and connection-string
password fields - and a match is replaced with a stable, named placeholder
such as[redacted:github-token], never simply deleted. The result reports
Redactions: pattern name and count only, never the matched value.
Set-ShpRedactionPolicy/Get-ShpRedactionPolicy/Clear-ShpRedactionPolicy
add custom patterns on top of the built-ins, in the sameName(Pattern)
shapeSet-ShpToolPolicyalready uses, and the custom policy travels to
everyInvoke-ShpBatchworker the same way the tool policy does. Redaction
is on by default; pass-DisableRedactionto send a call verbatim. Only the
model's own reply is exempt - it was generated from input already redacted
before it was sent, so it cannot reflect a secret it was never shown, and a
-JsonSchemareply still parses ontoContentObjectexactly as it would
with redaction off.
See specs/026-egress-redaction.md. -
An unattended run is now a supported, deliberate profile rather than an
accident.Invoke-Shp,Invoke-ShpBatchandInitialize-Shptake
-NonInteractive, on automatically when$env:CIis truthy and overridable
with-NonInteractive:$false. It withdrawsask_user, refuses-Confirm
instead of silently answering it yes, and refuses the device-code flow before
the browser launch and the clipboard write - because a prompt on a runner does
not fail, it burns the job's whole timeout and then fails for the wrong
reason. A model that callsask_useranyway ends the turn with
ShpNonInteractivePromptrather than continuing on an answer nobody gave.
See specs/025-ci-profile.md. -
In CI, the default Copilot backend is refused unless you opt in. That
backend reaches the Copilot endpoints with the public VS Code client id, on
the token owner's personal entitlement - fine for a shell, a decision for a
pipeline. Configure an OpenAI-compatible endpoint instead, or set
SHELLPILOT_ALLOW_COPILOT_BACKEND_IN_CI. The error carries the id
ShpCopilotBackendInCiand names both remedies, and it is raised before
the token exchange so nothing is spent proving the point.
A warning was the obvious alternative and is the wrong shape: nobody reads a
warning in a green build, which is the whole finding behind-FailOn. -
$env:SHELLPILOT_API_BASEand$env:SHELLPILOT_API_KEYare read as
backend defaults, below an explicit-ApiBaseand the session context and
above the built-in Copilot endpoint - so a pipeline points ShellPilot at its
own endpoint with the variables it already injects. -
Test-ShpCiReadinessreports the whole resolved profile - token source,
backend, interactive capability,Ready, and a list of named issues - without
sending a chat request or exchanging a token. The three things an unattended
run needs are decided by three different precedence chains with silent
fallbacks, so a misconfigured job otherwise fails minutes later at the first
Invoke-Shpwith whichever chain gave out first. No secret is returned: the
credential and the API key are reported by source only, and the endpoint has
any URL credentials redacted.
Changed
-
An alternative backend (
-ApiBase) no longer carries the Copilot session
token. It previously fell back to that token whenever noApiKeywas
configured. That was reachable before only throughSet-ShpContext; reading
ApiBasefrom the environment would have let anything able to set a variable
on a runner redirect a live Copilot credential to a host of its choosing.
With a key the request carries the key; without one it carries no
Authorizationheader at all, which is what a local server expects anyway.
A URL's credentials are also redacted from the result'sEndpointmember. -
A pipeline step can now fail when the call did not deliver. A budget
overrun was aWrite-Warningplus aBudgetExceededproperty, so an
unattended run exited0on a truncated or abandoned answer and wrote the
half-finished artifact anyway.Invoke-Shp -FailOnturns five named outcomes
into terminating errors:BudgetExceeded,Truncated,ToolIterationLimit,
NoContentandSchemaMismatch.
Each carries a distinct, documentedFullyQualifiedErrorId
(ShpBudgetExceeded,Invoke-Shpand so on) so a wrapper branches on the
condition instead of matching an English message - which is what
MaxToolIterationsforced, having used its own message text as the error id.
Omitting-FailOnchanges nothing, and the turn's side effects are
unchanged either way: the call is evaluated last, after the result is built,
the usage row written and the session chat updated, so-FailOndecides only
whether the call ends with a result or with an error. The whole
ShellPilot.Resultrides onErrorRecord.TargetObject, so acatchblock
still knows what the abandoned turn cost.
ShellPilot never sets$LASTEXITCODEand never callsexit- a module that
terminates its host cannot be composed - so the exit code stays the caller's
job; the comment-based help carries thetry/catchplusexit 1wrapper.
See specs/024-pipeline-failure-semantics.md. -
Invoke-ShpBatch -FailOnand-FailBatchOnAnyItem. The same five
conditions apply per item, and a tripped one never aborts the batch: the item
reportsSuccess = $falsewith the branchableErrorRecordintact while every
other item runs to completion.-FailBatchOnAnyItemraises one terminating
error afterwards,ShpBatchItemsFailed,Invoke-ShpBatch, carrying a
ShellPilot.BatchSummarywithTotalCount,SucceededCount,FailedCount,
SkippedCountand theFailedresults themselves. -
ShellPilot can authenticate without a browser and without a token file.
Initialize-Shpwas device-code only andGet-ShpSessionTokenopened with an
unconditionalTest-Paththrow, so a CI runner - no profile, no browser -
could not sign in at all. Two in-memory sources now exist:
Set-ShpContext -GitHubTokenfor a caller that already holds the value, and
$env:SHELLPILOT_GITHUB_TOKENfor a pipeline that injects it as a secret.
Neither is ever written to disk;Initialize-Shpremains the only code path
that writes a token file.
One private resolver owns the order - explicit-TokenPath, then the session
context, then the environment variable, then the default token file - because
the module's existing rule is that any option family with more than one source
resolves in exactly one place, and a credential is the worst place to have two
call sites disagree. The session token is supplied toInvoke-ShpBatch
workers too, which inherit nothing.
A set-but-emptySHELLPILOT_GITHUB_TOKENis rejected, not skipped. That
state is what a pipeline produces when its secret fails to expand, and falling
through to the token file would authenticate the run as whoever last signed in
on that machine - green build, wrong identity. The token is masked as***by
Get-ShpContext, matching-ApiKey, and a verbose line names which source
supplied it and never the value.
See specs/023-non-interactive-token.md. -
Invoke-Shp -Attachmenttakes a file of any format.-Imagewas the only
attachment surface and accepts images only, so everything else had no route to
the model at all - naming the path in the prompt works for a text file and
fails for anything else, becauseread_filedecodes text and a.msgcomes
back as 2,257 characters of raw bytes.
Each file is classified by content, not extension, and routed three ways:
an image joins the vision path; a text file of any encoding is decoded and
inlined, capped with the usual truncation marker; and a binary file is
described rather than decoded - the model is given the absolute path, the
size, the format identified from the file's magic number, and a hex preview of
the head.
ShellPilot deliberately converts nothing. A converter table would need a
dependency and a new extractor per format, against a module whose stated
constraint is no runtime dependencies - while the model already hasread_file
andrun_commandand lacked only the first bytes. Live-verified on the.msg
that motivated this, with neither Outlook nor Python present: given
d0 cf 11 e0 a1 b1 1a e1the model recognised the OLE2 compound file and
decoded the sender, subject, timestamp, flight number and booking code itself
in four iterations.
Attachment content is inserted into the user message and framed as data,
never as a system instruction, because a document is untrusted content
(spec 019's threat model). The payload is kept out of the replayed history, so
a continuation records that files were attached without resending them. The
result reportsAttachments.
See specs/022-file-attachments.md. -
Register-ShpMcpServer,Get-ShpMcpServerandUnregister-ShpMcpServer
attach MCP (Model Context Protocol) servers so their tools are offered to the
model alongside the built-ins and any registered user tools. Opt out for one
call withInvoke-Shp -DisableMcp; nothing is offered until you attach a
server, so the default posture is unchanged.
Both protocol eras are supported over stdio. Verified from the
specification rather than from memory, which changed the design: the current
revision 2026-07-28 removed theinitializehandshake entirely - a modern
request is stateless and carries its protocol version and client capabilities
in_meta, withserver/discoveras a mandatory RPC - while nearly every
server in the field still expects the handshake. The client therefore probes
withserver/discoverand falls back toinitializeon any other error or a
timeout, never keyed to one error code. Both eras were negotiated live, and
the real Azure MCP Server 2.0.5 turned out to be a legacy server - which
settles whether supporting both was worth it.
See specs/021-mcp-server-support.md. -
Tool names are namespaced
mcp_<alias>_<tool>using the alias you chose,
not the server's self-reported name, which the protocol says nothing
verifies. The sanitiser is written to a measured constraint, not a guessed
one: the Copilot endpoint enforces^[a-zA-Z0-9_-]{1,128}$, so a dot - which
MCP explicitly permits in a tool name - becomes_. This matters because a
rejected name identifies the offending tool only by its index in the
request, and the resulting 400 is masked by the chat-to-responses fallback,
surfacing as "model ... does not support Responses API" - a true statement
about an entirely different problem. -
Get-ShpToolnow lists MCP tools next to user tools, with a newOrigin
column (UserorMcp) and the contributingServer.Invoke-Shpreports
McpEnabled,McpServersAvailable,McpToolsAvailableandMcpToolsCalled,
and an MCP call emits the sameToolCallprogress record as every other tool,
so a host renders it identically.
Fixed
- A failed batch item no longer reports zero cost.
Invoke-ShpBatchItem
built its result from theErrorRecordalone, so a call that threw
contributed nothing to the batch spend accumulator. That was invisible while
every failure was an HTTP refusal that had cost little, but an
Invoke-Shp -FailOnstop is a completed, billed turn - a sweep of truncated
replies would have overrun-MaxBatchBudgetUSDsilently. A failing item now
recovers its result fromTargetObjectand keepsModel,FinishReason,
UsageandCostUSD.Contentis still withheld from an unsuccessful item.
Security
- An MCP server is a third-party process running with your privileges, and
there is no sandbox. Its tool names and descriptions are untrusted input the
model reads on every round-trip, and its results are untrusted content. Three
controls follow, each stated with its limits:- Nothing is discovered. No scan of the working directory,
.vscodeor a
user profile.Register-ShpMcpServer -Pathreads a file you name. A
configuration file is a command line, and a command line is arbitrary code. - The tool list is frozen at registration. The client opens no
subscriptions/listenstream, so it receives no
notifications/tools/list_changedand a server cannot add or alter tools
after you approved them. Refreshing is an explicit-Force. This does not
stop a server changing its behaviour. - The child environment is built, not inherited. Unlike
run_command,
which deliberately inherits the whole block for compatibility, an MCP child
starts from a minimal base plus exactly the variables you name in
-Environment, so an ambient$env:credential is not handed to somebody
else's process.
- Nothing is discovered. No scan of the working directory,
Set-ShpToolPolicycannot gate an MCP call, and this is stated rather than
implied. Its rules match resolved filesystem paths and leading command
tokens; a tool call has neither. Demonstrated in one live Turn under
Read(<repo>/**): the built-inread_filewas denied with a reason and the
MCP tool call ran. A policy that scopesread_fileto one directory does
nothing about an attached filesystem server. Reduce reach at attachment
instead, with-ToolName.- The injection path was measured, not asserted. With a hostile instruction in a
tool description only, the model read a decoy credentials file and passed
its contents to the third-party server as a tool argument - the server's own
log confirms receipt. The same run with-DisableFileAccessread nothing and
leaked nothing. - A configuration entry asking for
sandboxEnabledwarns and still starts:
a configuration written for a sandboxing host is exactly the one you want to
reuse. The gap is surfaced twice rather than being fatal once - a warning that
says the server is starting unsandboxed, plusSandboxRequestedon the server
record, because a warning scrolls away and a property does not. An entry
carrying an unresolved${...}variable is refused, because starting the
literal text would run a different command than the file describes. Get-ShpMcpServerreports environment variable names only, never values.
Known limits
-
stdio transport only. Streamable HTTP is deferred with the MCP Authorization
framework; a half-authorised HTTP client would be worse than none. -
Invoke-ShpBatchdoes not use attached servers and warns once. A worker
runspace inherits nothing, so replaying an attachment would start one copy of
every server per worker. -
A server that exits is marked
Faultedrather than restarted automatically.
The specification says a client SHOULD restart one; automatic respawn of
third-party code inside an unattended loop turns one crash into a crash loop
nobody is watching. Re-attach withRegister-ShpMcpServer -Force. -
Resources, prompts, sampling, elicitation and roots are out of scope for this
version. Client capabilities are declared empty, so a modern server needing
one gets a protocol error naming it rather than hanging. -
The cached OAuth token is now protected at rest, closing open decision #5.
On Windows it is DPAPI-encrypted for your account; on every platform the file
is restricted to the current user. Measured before the change, the real file
was 40 bytes of clear text with the profile's inherited ACL, readable by
BUILTIN\Administratorsas well as by you.
The file is self-describing -SHPv1:DPAPI:...orSHPv1:NONE:...- and
Initialize-Shpreports which protection it applied, because a scheme that
silently degrades to clear text is worse than clear text. On Linux and macOS
there is no DPAPI equivalent without a dependency, so the scheme isNONE
and file permissions (mode 600) are the only control; that is stated in the
file and on screen rather than implied.
No runtime dependency was added -ConvertTo-SecureStringis built in.
SecretManagement was considered and rejected: SecretStore prompts to unlock,
which would break every unattended run, and configuring it not to prompt
reduces it to file permissions while costing this module its empty dependency
list. This buys protection against another principal on the machine; it does
not protect against code running as you, and no candidate scheme would.
See specs/020-encrypted-token-storage.md. -
An existing clear-text token file keeps working and is upgraded in place by
Initialize-Shpwithout re-authenticating, so nobody needs a browser just
to gain protection. A protected file that cannot be decrypted - typically one
copied from another machine or account - throws with an actionable message
namingInitialize-Shp -Force, rather than sending a ciphertext to the
service as a bearer token. -
Set-ShpToolPolicy,Get-ShpToolPolicyandClear-ShpToolPolicyscope what
the unsandboxed file and shell tools may reach, so an unattended run can be
given the access it needs instead of the caller's entire filesystem and shell.
Rules are writtenRead(./src/**),Write(./out/**),Shell(git status),
with a leading!to deny; any matching deny beats every matching allow.
Nothing changes until you set a policy - and once you do, the model is
denied by default. Refusals are reported on the result asToolCallsDenied,
and the policy travels into everyInvoke-ShpBatchworker, because a worker
inherits no module state and would otherwise be the one unguarded path.
This closes a real gap rather than a symmetry:-Confirmis interactive only
so an unattended run never prompts, and-DisableFileAccess/
-DisableTerminalare all-or-nothing, so the only safe unattended setting was
"all tools off".
See specs/019-tool-access-policy.md. -
Paths are matched on the absolute, link-resolved path rather than the
string the model supplied, so neither a..segment nor a directory junction
walks out of an allowed root, and patterns are anchored so a rule forout
cannot matchoutsider.run_commandis matched on whole leading tokens and
refuses any shell metacharacter whatever the rules say, because
git status; curl ...would otherwise pass a rule that only ever meant
git status. AShellrule constrains which program runs, not what it does;
the spec states that limit and the others plainly. -
Invoke-Shp -RetryDelaySec, and-TimeoutSec,-MaxRetryCount,
-RetryDelaySecand-NetworkOutageToleranceSeconGet-ShpModeland
Request-ShpEmbedding. All four now resolve identically everywhere - explicit
parameter, thenSet-ShpContext, then the built-in default - and apply to
every request the module makes, including the OAuth-to-session token
exchange,/modelsand embeddings. Those three previously called the retry
wrapper with the module's own defaults, soSet-ShpContext -TimeoutSec 10 -MaxRetryCount 0silently did not apply to them; andInvoke-Shpresolved
its options after the token exchange had already run, so an explicit
-TimeoutSecnever reached the one request that gates every other one. There
is deliberately no exemption for the auth handshake: the exchange is cached,
so-MaxRetryCount 0costs at most one un-retried attempt per session, and
outage tolerance is a separate option, so a dropped connection during auth is
still ridden out. -
Compress-ShpChatdrops the oldest exchanges from the running session
conversation so a session that has outgrown the model's context window becomes
usable again without discarding it. Until now the only ways out were
Clear-ShpChatand a stateless-Historycall, and both throw the whole
conversation away.Invoke-Shpwrites the conversation back only when a call
succeeds, so a refusal leaves it pinned and every later call is refused
identically - measured at 0 successes in 108 retries. Measured against the
live service, dropping the single oldest exchange was enough to restore a
pinned session, so discarding all of it was never necessary. The first
exchange (usually the task definition) and the newest exchange are kept,
exchanges are dropped as whole user/assistant pairs,-WhatIfreports the
plan without changing anything, and the returned report says exactly what
went. Conversation turns are never elided automatically: a tool result is
scaffolding the model produced for itself, but a user turn is something the
user said, and a model answering from a silently truncated history can
confidently contradict it.
See specs/018-conversation-history-overflow.md. -
Invoke-Shpnow warns before sending once the context guard has elided
every tool result it may and the conversation is still over budget, naming
Compress-ShpChat. It is phrased as a fact about the guard rather than a
prediction about the service, becauseConvertTo-ShpTokenCountwas measured
at +30% / -12% against the service's own token count and is not accurate
enough to refuse a call on. The warning fires once per turn, not once per tool
iteration. -
The context-window guard now sizes itself from the model in use. Left unset,
-MaxContextWindowTokensresolves in four steps - the parameter, then
Set-ShpContext -MaxContextWindowTokens, then the model's own advertised
limits, then the built-in 900000 - and the resolved figure and the step that
produced it are reported on the result asContextBudgetand
ContextBudgetSource. The third step is not simply the advertised context
window: that figure covers prompt plus completion, so the model's output
allowance is reserved first and a 10% margin taken from what remains.
Measured against the live service,claude-haiku-4.5advertises a 200000
window with a 64000 output cap and refuses a prompt at 136000 - exactly
200000 - 64000 - so a margin on the advertised window alone would still have
missed it. No advertised pair on offer resolves above 900000, so this can only
ever tighten an existing caller's guard, never loosen it.
See specs/017-context-window-budget-from-model.md. -
Get-ShpModelrecords each model's advertised context window and output cap
in a session cache as a side effect. That cache is what lets the guard resolve
a real window with no request of its own: a turn is a loop, so consulting
/modelsper turn would add a round-trip to calls that are otherwise local.
Until something fills it -Get-ShpModel, orGet-ShpModelName, which calls
it - the guard uses the fallback, which is no model's real window and is too
permissive for 22 of the 36 models that advertise one. RunGet-ShpModelonce
per session to fix that.Invoke-ShpBatchcopies the cache to every worker,
andInitialize-Shpdiscards it on re-auth. -
A model that is absent from a model list that was fetched now warns once per
model per session that the guard is running on the fallback, in the same
spirit asPriced/PriceTableKeymaking an unpriced call observable. A
cache that has simply never been populated - the default state of every
session - stays quiet. -
Get-ShpUsagenow records calls that failed, not only the ones that
succeeded. A failed call carriesSuccess$falseand the failure message on
Error, plus whatever spend its completed round-trips had already incurred.
Both halves of that matter: a success rate computed from the old log was 100%
by construction, because only successes were in it; and a turn is a loop of
billable round-trips, so a turn refused on its third round-trip really was
charged for the first two and reported nothing. Only a call that reached the
API is recorded - a parameter combination rejected before any request was
never a call.Invoke-ShpBatchinherits this, so a failed batch item now
shows up inGet-ShpUsagetoo.
See specs/016-failed-call-usage-accounting.md. -
Get-ShpUsage -SummarygainedSucceeded,Failed,TotalDurationMs,
MeanDurationMs,FirstCall,LastCallandElapsedMs, and theByModel
breakdown gainedSucceeded,FailedandDurationMs.ElapsedMsis
wall-clock between the first and last call and is deliberately not the sum
ofDurationMs: underInvoke-ShpBatchthe calls overlap, so the sum can far
exceed the elapsed time and the ratio between them is the speed-up the batch
bought. -
Get-ShpUsage -Sinceand-Beforefilter by time window, so one phase of a
run can be summarised without clearing the log between phases. Both apply to
the records and to-Summary. There is deliberately no-GroupBy:
Get-ShpUsagereturns the records, soGroup-Objectalready groups by any
field, andByModelis pre-aggregated only because that split is the common
case. -
Invoke-ShpBatchruns many independent prompts concurrently and returns one
ShellPilot.BatchResultper input, carrying the answer, that item's usage and
cost, and - when the call failed - the error.-ThrottleLimitbounds how many
calls are in flight (default 4, deliberately conservative), and prompts can be
piped in as plain strings or as objects withPromptand an optionalId.
Three guarantees are the point of it. Every item is stateless: a batch never
reads or writes the session conversation, so it cannot reproduce the
accumulation that makes a serial loop ofInvoke-Shpcalls grow until the
model refuses it withmodel_max_prompt_tokens_exceeded. Failures are
isolated: one failed call never aborts the batch and nothing is written to the
error stream, because an error raised from a worker obeys the caller's
$ErrorActionPreferenceand would destroy every result underStop; check
SuccessandErroron the results, and a single summary warning names how
many did not complete. And identity is carried: results arrive in completion
order, so every one hasIndex,Idand the originalInputObject.
-MaxBatchBudgetUSDcaps the whole run as a gate on dispatch - calls already
in flight are never cancelled. Streaming,ask_userand progress events are
off for every item, for reasons the help gives. The model, sampling
parameters,-ResponseFormat/-JsonSchema,-SkillPath,
-InstructionRoot, the isolation switches and the connection options are all
forwarded, and per-item usage is merged intoGet-ShpUsage.
See specs/015-batch-execution.md. -
Invoke-Shp -MaxContextWindowTokensandSet-ShpContext -MaxContextWindowTokensset the token budget above which a turn elides its
oldest tool results, with the usual precedence of explicit parameter, then
session context, then the built-in default. The built-in 900000 is a fallback
rather than any model's real window -claude-haiku-4.5is 136000 - so the
guard could never fire for a smaller model; set this from the model's own
MaxContextWindowTokens(seeGet-ShpModel) to make it fire when it should.
0disables the guard. The default is unchanged, so existing calls behave
exactly as before. Note this bounds tool results only: the session
conversation is never elided, so a long loop of calls still needs
Clear-ShpChator-History. -
Invoke-Shpnow explains a rejection it cannot recover from. A service reply
ofmodel_max_prompt_tokens_exceededis emitted as a warning naming the real
cause - every-Promptcall continues the session conversation, so a loop of
calls grows until it no longer fits - and the two remedies. Left unexplained
this reads as a bare 400 and gets mistaken for rate limiting. -
Invoke-Shp -Temperature,-TopPand-Seedcontrol the model's sampling,
so a call that has to be reproducible (grading or judging in an evaluation
harness) can pin itself with-Temperature 0and a variance measurement can
fix its operating point instead of inheriting the backend default. Each field
is omitted from the request body entirely when the parameter is not passed, so
existing calls are unchanged.-Temperatureis validated against 0..2 and
-TopPagainst 0..1 before the request is sent, and a model that rejects a
field fails the call rather than having the field silently dropped.
The values used are reported on the result asTemperature,TopPand
Seed. See specs/014-sampling-parameters.md. -
Invoke-ShpandGet-ShpCostEstimateresults carryPricedand
PriceTableKey, so a call the price table cannot cost is no longer
indistinguishable from a free one.PriceTableKeystays populated even when
nothing matched, naming the key that was looked up and missed, and the first
call for an unpriced model warns once per session rather than once per tool
iteration.CostUSDandCreditsare unchanged and stillnull- never0-
when no rate is found.Pricedis also recorded on eachGet-ShpUsageentry. -
Resolve-ShpErrorexplains the last error in the session and suggests a fix.
It takes an error record ($Error[0]by default, or from the pipeline), sends
the message, exception type, category, target, failing command line and script
stack trace to the model, and returns the usualInvoke-Shpresult. Every
tool is disabled unless-EnableToolsis passed, so diagnosing an error
cannot touch the machine. -
Invoke-ShpsupportsShouldProcess.-WhatIfdry-runs a whole agent turn -
the model still plans and calls tools, butwrite_file,create_directory,
run_commandand user-registered tools are skipped and told they were not
approved - and-Confirmprompts before each of those calls. Default
behaviour is unchanged. -
Invoke-Shp -MaxBudgetUSDstops the tool-calling loop once the turn's
estimated spend passes the cap, and the result carries a newBudgetExceeded
flag. -
Invoke-Shp -AppendSystemPromptadds inline system instructions in either
parameter set, so a file-driven system prompt can still be topped up for a
single call. -
Invoke-Shp -AllowPrivateNetworkopts thefetch_urltool back in to
loopback, link-local and private addresses. -
Start-ShpChatgained the/models,/history,/retryand/usage
commands./retrydrops the last exchange and resends the previous prompt. -
The price table supports a long-context tier. An entry may carry a
LongContextblock with aThresholdin input tokens plus its own rates, and
the cost breakdown now reportsTierandTiersUsed.
Changed
-
Set-ShpContext -TimeoutSec's documented built-in default was wrong. The
help said 100 seconds; the module variable holding it was never read and the
real default is 0, meaning no explicit timeout - the sharedHttpClientis
built with an infinite timeout so a long streamed turn is not cut off
mid-response. The documentation is corrected and the dead variable removed. No
behaviour changed, because the 100 had never applied to anything. -
The
model_max_prompt_tokens_exceededwarning now explains that a failed call
is not written back, so the conversation stays pinned and every retry fails
identically, and it namesCompress-ShpChatalongsideClear-ShpChatand
-History. -
Get-ShpUsage -Summary'sCallsandCostUSDwill report different
numbers for a session in which something failed.Callsnow counts calls
attempted; it previously counted records, and only successes were recorded,
so it meant "calls that succeeded". ReadSucceededfor the old number.
CostUSDnow includes the spend of turns that failed after one or more
billable round-trips, which was previously dropped. Both are corrections
rather than regressions - that money was really spent, and those calls were
really made - but a caller readingCallsas a success count must move to
Succeeded. Nothing changes for a session in which nothing failed. -
The HTTP retry backoff is now jittered: half the exponential delay plus a
random amount up to the other half. A purely deterministic backoff
synchronises under concurrency - severalInvoke-ShpBatchworkers refused by
the same 429 would sleep identical durations and re-fire together, recreating
the burst that caused the refusal. ARetryDelaySecof0still yields
exactly0, so no existing call path changes.
Fixed
-
An oversized image is now re-encoded to fit instead of failing the call.
The request-body guard below was correct but unhelpful: an ordinary phone
photo is already over the 5 MiB ceiling, so-Imagerefused a perfectly
normal attachment and told the caller to go and resize it.
Resolution is the last thing given up, and that order was measured. A
photographed departure board read correctly at every size from 512px to
4096px - but a scanned page of 9pt text was refused as illegible below 1024px
and, at 1568px, returned a confidently wrong file number (4-C 1137/26
for4 C 1187/26). A silently wrong answer is worse than a refusal, so JPEG
quality is reduced at full resolution first and dimensions change only when
compression alone cannot reach the budget. On the photo that prompted this,
quality alone freed 35% (3,943,304 to 2,580,566 bytes) with all 4096x3072
pixels kept.
A warning always states what it cost, and a dimension change warns separately
that small text may no longer be legible. Re-encoding needs an in-box image
codec, so it is Windows-only; elsewhere the call is refused with its sizes
named, as before. -
Invoke-Shp -Imageno longer accepts a file that is not an image. Any
path was read and embedded as a base64 data URI, with an unknown extension
falling back toapplication/octet-stream- so attaching a.msg,.pdfor
.docxalongside a screenshot sent a payload no vision model can see, at its
full base64 weight, and the only symptom was a refusal from the service. The
extension is now checked against the types a vision model reads (.bmp,
.gif,.jpeg,.jpg,.png,.webp) and anything else is rejected by
name before the call. The rejection also says what to do instead, because the
answer differs by file: a text file needs no attachment at all - naming
its path in the prompt lets theread_filetool fetch it - while a binary
document (.msg,.pdf,.docx,.xlsx) has to be converted to text first,
sinceread_filereads text and would otherwise hand the model raw bytes. -
An oversized image is now refused locally instead of as a bare
413 Request Entity Too Large. The ceiling is a gateway limit measured
live rather than assumed: binary-searched against the real endpoint, a body of
5,235,612 bytes was accepted and 5,237,612 was refused, putting it at exactly
5 MiB. Because base64 costs 4 bytes per 3, an image much over ~3.5 MB on disk
cannot fit, which is easy to hit with an unedited phone photo.-Imagenow
sums the encoded size of every local attachment - anhttp(s)URL is exempt,
being sent by reference - and throws before the round-trip with each file's
size on disk, its encoded size, the budget, and what to do about it. -
A
413that does survive now says how large the request was and how large
it may be. The gateway's body is the bare phraseRequest Entity Too Large,
which names neither figure, and the model never saw the request - so the
existing context-window advice does not apply and no token count explains it.
Both HTTP senders now append the byte count actually sent and the ceiling. The
other 413,model_max_prompt_tokens_exceeded, carries its own JSON error
object and is left untouched.Turn resolved its short-lived Copilot session token once, before the loop, and
then reused that one credential for every iteration - so an agentic Turn that
ran longer than the token's lifetime failed part-way through (observed at
iteration 41) withIDE token expired: unauthorized: token expired. The
sign-in was never the problem, and resending the prompt was not a recovery: it
started a brand-new Turn at iteration 1 and threw away all the completed work.
Invoke-Shpnow re-resolves the session token before every iteration, which
costs nothing until it is needed because a still-valid token is served from
the in-memory cache without a round-trip. A 401 that still slips through is
recovered once by exchanging a fresh token and retrying the same iteration -
the answer is not duplicated, the iteration counter does not advance, and
usage is not double-counted. A revoked or unauthorized GitHub OAuth token
still fails, once, with a message namingInitialize-Shp, and an alternative
backend authenticating with your own API key never has itsAuthorization
header rewritten. -
The session-token safety margin is now 300 seconds instead of 60, so a token
is never handed to a request it cannot outlive. A single tool iteration can
take minutes for a reasoning model working through a large tool result, so a
token with 61 seconds left used to be served and then die on the next request.
The cost is at most one extra token exchange per five minutes of an otherwise
cached session. -
run_commandnow runs the command it was given. Every unescaped double
quote was being stripped before the child shell saw it, so the model's
Write-Output "set to $env:X"ran as four bare arguments and
--pretty=format:"%h %s"reached git as two. The command line was handed to
Start-Process -ArgumentListas one array element; PowerShell joins that array
into a single string, and the native argument parser then eats the quotes. It
failed plausibly - the first example returned exit code 0 with output that
looks like output - and the returned envelope echoed the command that was sent
rather than the one that ran, so every transcript andCommandsRunentry
recorded a command that never executed. The child is now started through
System.Diagnostics.ProcessStartInfowith anArgumentList, which quotes each
element the way the platform requires, so the command is passed through
verbatim and what the envelope reports is what ran. Single-quoted commands,
the JSON envelope,MaxCharscapping, the timeout envelope, the process-tree
kill, the default working directory and theworkingDirectoryoverride are all
unchanged, as is the environment the child inherits. -
Streamed
Invoke-Shprequests now use the same retry wrapper as buffered
requests. HTTP 429/5xx responses are bounded byMaxRetryCount, and a true
no-response transport failure usesNetworkOutageToleranceSec. The classifier
reads the streaming sender's structuredStatusCodebefore its bare
HttpRequestExceptiontype, so a permanent 400 fails after one attempt instead
of burning the network-outage budget. If reading an error body itself fails,
the known status is preserved and both response and request are still disposed. -
Invoke-Shp -History @()now genuinely starts from nothing.-Historyis
documented as stateless, but an empty array is falsy and the check tested
truthiness, so an explicitly empty history silently fell through to seeding
the call from the session conversation - the opposite of what was asked for.
Binding is now the test, matching the module's rule for every other optional
value whose type has a meaningful default. -
The streaming sender now carries the same structured error as the buffered
one. Streaming is theInvoke-Shpdefault, so this was the common path on
which a caller still had to match substrings in an exception message. A
non-success status fromInvoke-ShpStreamRequestnow raises an ErrorRecord
with the body onErrorDetails.Messageand aShellPilot.HttpErrorDetailon
TargetObject, which is also the only programmatic home the status has on
that path -HttpRequestExceptioncarries no response. The exception type and
the message wording are unchanged. -
A failed buffered request now hands the service's error to the caller as
data instead of only as text.Invoke-ShpHttpRequestraises a built error
record rather than a bare exception, so$_.ErrorDetails.Messagecarries the
response body the wayInvoke-RestMethoddoes, and$_.TargetObjectcarries a
ShellPilot.HttpErrorDetailwithStatusCode, the service's ownErrorCode
andParam, itsMessage, the whole rawBodyand theRequestUri- so a
script can branch on the error code without matching substrings in an
exception string.Invoke-Shp's own catch block has always read
$_.ErrorDetails.Messagefirst, a member that until now was never populated
and silently fell through on every failure. The exception object and the live
response it carries are unchanged, so the 429/5xx retry and the network-outage
budget classify a failure exactly as before, and the failure is now identified
by the stableShpHttpRequestFailed,Invoke-ShpHttpRequesterror id instead of
by an error id that repeated the whole message. BecauseErrorDetails.Message
replaces the record's display text, it is capped like the exception message;
TargetObject.Bodykeeps the body whole. This covers every buffered call -
Invoke-Shp -DisableStreaming, every/responsesturn,/models, the token
exchange and embeddings; streamed requests expose the same contract through
their sender. -
The streaming sender no longer quotes an unbounded error body. A non-success
status fromInvoke-ShpStreamRequestput the service's whole response into the
exception message, so a 5xx from an intermediate proxy could turn an entire
HTML page into an error message. It is now capped at the same 2000 characters
with the same...[truncated, original N chars]marker as the buffered
sender. The wording is deliberately unchanged: that exception carries no
response, so its URI and status exist only in the text. -
A failed non-streaming request now reports what the service objected to. The
buffered sender read the error response body and then discarded it, so a
rejected request surfaced only as
Response status code does not indicate success: 400 (Bad Request).The
service's own explanation is now quoted after the status line as
Response body: ..., capped at 2000 characters with the usual
...[truncated, original N chars]marker. The exception type and the response
it carries are unchanged, so the 429/5xx retry and the network-outage budget
still classify a failure exactly as before. This also revives the API-shape
fallbacks inInvoke-Shp, which match the error text forstore,
unsupported_api_for_model,invalid_request_bodyandreasoning/
summary: they were unreachable whenever the reply was buffered, so for
exampleInvoke-Shp -Model gpt-5.5 -DisableStreamingfailed on a bare 400
instead of falling back to/responsesthe way the streaming path already did. -
Invoke-Shpnow changes the API shape at most once per turn. Both shape
fallbacks rewind the iteration counter before retrying, so-MaxToolIterations
never bounded them, and a service that refused/chat/completionsand
/responseswith the same code could bounce a turn between the two
indefinitely - one billable request per hop. The first switch is still made;
the second refusal now surfaces as an error. -
Corrected the GPT-5.6 rates against the published GitHub Copilot billing
table.gpt-5.6-lunawas charged 5x its real rate (now 0.20/0.02/0.25/1.20
per 1M tokens) andgpt-5.6-terra25% over (now 2.00/0.20/2.50/12.00); all
three GPT-5.6 models bill a cache write that the table previously recorded as
$null, so cache-write tokens were costed as free. -
Added the missing
grok-4.5rate, so xAI calls are no longer unpriced. -
The
fetch_urltool no longer reaches private networks. Every URL, including
each redirect target, is checked before the request: onlyhttpandhttps
are allowed, and host names must resolve to publicly routable addresses.
Loopback, link-local (including the169.254.169.254cloud metadata address),
RFC 1918, carrier-grade NAT,0.0.0.0/8, multicast and their IPv6 equivalents
are refused, as are IPv4-mapped forms and names that fail to resolve.
Redirects are followed manually, up to five hops, so a public URL can no
longer bounce the model into the host's own network. -
Corrected the
gpt-5.6rates, which shipped as placeholders.gpt-5.6-luna
was charged five times its real rate (now 1.00 / 0.10 / 6.00 USD per million
input / cached-input / output tokens) andgpt-5.6-terratwice
(now 2.50 / 0.25 / 15.00).gpt-5.6-solwas already correct. -
Cost is now calculated per round-trip instead of on the turn totals. A model's
long-context rate is selected by a single request's input size, so a turn made
of several smaller round-trips is no longer at risk of being priced as one
oversized request, and a genuinely oversized request is no longer under-priced
at the default rate. Added the published thresholds and long-context rates for
gpt-5.4,gpt-5.5,gpt-5.6-luna,gpt-5.6-sol,gpt-5.6-terraand
gemini-3.1-pro. -
Added the missing price-table entries for models the service advertises:
gemini-3-flash-preview,gemini-3.1-pro-preview,gemini-3.6-flashand
mai-code-1-flash-picker, which all reported empty cost and credit fields.
Also added published rates forclaude-fable-5,claude-opus-4.8-fastand
kimi-k2.7-code. -
Request bodies are now serialised with a stable key order. PowerShell
hashtables have no defined enumeration order and .NET randomises string
hashing per process, so the same payload could serialise differently between
runs and defeat backend prompt caching. -
Invoke-ShpandGet-ShpCostEstimatenow reportCostUSDandCreditsfor
claude-opus-5andclaude-sonnet-5. Neither model had an entry in
data/PriceTable.psd1, and the price lookup matches the model id exactly, so
every call using them returned empty cost, credit and cost-breakdown fields.
The rates are the published ones: Opus 5 at 5.00 / 0.50 / 6.25 / 25.00 USD per
million input / cached-input / cache-write / output tokens, and Sonnet 5 at
its introductory 2.00 / 0.20 / 2.50 / 10.00 (the standard
3.00 / 0.30 / 3.75 / 15.00 takes effect on 2026-09-01).